xuzhengfu / pilot

进入编程世界的第一课
1 stars 0 forks source link

p2-6-string-data 第六章 字符串数据 #36

Open xuzhengfu opened 4 years ago

xuzhengfu commented 4 years ago

1. 引子:进入数据的世界

前面我们深入学习了函数的方方面面,就像打开了一个琳琅满目的工具箱,但如果没有用武之地,再强大的工具也会变得无趣,来自现实世界里的各种数据,提供了函数们施展拳脚的空间。

我们编写程序是为了让计算机帮助我们解决现实世界的问题,我们一般会对这些问题建立一个数据模型,用计算机的数据结构来表达这个模型,然后编写合适的算法(函数)来处理这个模型,最终得到我们要的结果。计算机程序千变万化,但不离其宗。

从这一章开始,我们就要进入 “数据” 的主题,来看看程序中常用的 数据模型 和 结构 有哪些,又该如何使用它们。

你已经学习了 Python 中最基本的数据类型:整数、浮点数、布尔、字符和字符串,这些类型能很好的解决现实世界里的这几种基础数据:

  • 整数、小数;
  • 逻辑真值和假值;
  • 一般的文本。

在接下去的几章,你将学到更多现实世界常见数据的表示和处理方法,比如:

  • 日期时间;
  • 电话号码、Email 等格式化文本;
  • 容纳一组数据,方便我们批量处理的数据容器(data container)
  • 如何定制更复杂的数据结构来解决问题,比如树型图(tree graph)

同时,你也会学习其他非常重要的知识,比如迭代器(iterator)模型 —— 这是 Python 中所有数据容器的基础。

在这一章先来看看如何用字符串表示各种数据。

2. 一切都是字符串

你在之前专门用了一章来学习 Python 的字符串,因为字符串实在是太重要了,和整数、浮点数、布尔等数据类型相比,字符串的应用要广泛得多也灵活得多。任何人类触及的数据,进入人眼,都可以视为一串文本,我们在看的书是文本,网页是文本,对话可以录下来再转换成文本,数据报表是文本,歌曲的乐谱和歌词都是文本,只要我们愿意,我们可以用字符串表达任何数据。

要和别人交换信息时,写一个邮件或者打印一张报告,本质都是文本;当某个程序要和别的程序交换数据时,一串约定好格式的字符串是最容易处理、兼容性最好的方法。

所以字符串在编程中有非常独特的地位,我们经常需要做的,就是把复杂的数据变成一个字符串,保存下来,传给别人或者别的程序;再把收到的字符串,按照约定好的格式解析出来,还原成各种数据。

下面我们就来看看如何用字符串表示一些常见数据,数字、日期时间,还有电话号码、Email 等。

3. 字符串 <=> 数值

把 数值类型 转换为 字符串 很简单,用我们讲的 f-string 就可以轻易做到,比如:

n = 440312
f = 3.1415926

s1 = f"{n}"
s2 = f"{f}"

要把 包含数值类型数据的字符串 转换成 对应的类型 则借助内置函数 int()float()

int(s1)
float(s1) # 字符串里包含的如果是整数,则不仅可以转为整数,也可以转为等值的浮点数
# 字符串里包含的如果是浮点数,则可以转为浮点数,但不能直接转为整数
float(s2)
# int(s2) 会导致 运行时错误:ValueError: invalid literal for int() with base 10,因为 '3.1415926' 无法转换为一个整数。

但下面这句是可以的,内层函数 float(s2) 返回是浮点数 3.1415926,外层函数相当于 int(3.1415926),效果是取整,结果是整数 3:

int(float(s2))
s3 = "440abc312"
s4 = "3.l4l5g26"

对上面两个字符串,如果我们尝试调用 int(s3) int(s4) float(s3) float(s4),也会出 ValueError 型的运行时错误,因为 s3 和 s4 的内容根本不是合法的数值。正如我们讲异常处理时说的:编写程序时并不知道自己可能会处理的数据是什么,有些输入如我们所想,有些则未必,当我们收到一个字符串而认为它 “应该是” 某种东西时,要格外注意,如果它不是会怎样,一个好的程序应该能正确处理那些即使 “不正常” 的输入,得到一个合理的(reasonable)结果。

我们把一个字符串转换为数值,比较安全的做法是这样的:

def is_float(s):
    try:
        float(s)
        return True
    except ValueError:
        return False

def is_int(s):
    try:
        int(s)
        return True
    except ValueError:
        return False

def parse_str(s):
    if is_int(s):
        print(f"输入是整数,其值为 {int(s)}。")
    elif is_float(s):
        print(f"输入是浮点数,其值为 {float(s)}。")
    else:
        print(f"输入“{s}”不是数值类型。")

在上面我们先定义了两个辅助函数 is_float(s)is_int(s),来判断输入的字符串是不是浮点数或者整数,或者更准确的说,输入是不是可以转换为浮点数和整数,判断的方法是利用 Python 提供的 “异常处理” 机制,就是尝试用 float() 或者 int() 函数来把字符串转换为浮点数或者整数,如果有 ValueError 异常抛出就说明输入的字符串不能转换。

随后在 parse_str() 函数中就用上述辅助函数先判明输入的情况,然后再做处理,这样就安全多了。

将字符串解读然后转换成某种特定类型的数据,通常我们用一个词 parse,我们在这个文档里讲的就是把字符串 parse 成各种类型数据。

Parsing a string is difficult. 所以我们一般情况下都会用现成的、对各种情况都有周到处理的第三方模块来做,比如把字符串 parse 成数值可以考虑 fastnumber 这个模块,有比内置函数更好的性能和更清晰易用的接口设计。

在命令行用 pip3 install fastnumbers 来安装这个模块。

$ pip3 install fastnumbers
Collecting fastnumbers
  Downloading fastnumbers-3.0.0-cp37-cp37m-macosx_10_6_intel.whl (43 kB)
     |████████████████████████████████| 43 kB 61 kB/s 
Installing collected packages: fastnumbers
Successfully installed fastnumbers-3.0.0

这个 fastnumbers 甚至可以转换各种奇怪的数字字符,随便写写的肯定做不到这种程度。如果我们必须自己写这样的代码,那就是对我们的思维严谨和周密性的考验了。有兴趣的话可以看一看 Python int() 方法的 源代码,或者 fastnumber 库的 源代码,不过这些源代码都是用 C 语言写的(Python 的官方解释器就是用 C 写的,所以叫 CPython)。

4. 字符串 <=> 日期时间

  • 语言定义的日期时间 数据类型,通常包含年、月、日、时、分、秒等属性,不同的编程语言的实现可能有所不同,有的语言还支持 Unix 式时间戳(Unix timestamp),也就是用 1970 年 1 月 1 日零时整开始流逝的秒数来表示的时间;

  • 用字符串表示的日期时间,比如 “2019-07-16 18:05:33”,不同国家地区对日期的表示格式是不一样的,比如我们中国习惯于“年-月-日”,美国习惯“月/日/年”,而世界上大部分其他国家都是“日/月/年”;时间的表示则有 12 小时和 24 小时制的区别,AM/PM 的不同写法等问题;还有遗留 数据用两位数字表示年份带来的臭名昭著的 “千年虫” 问题;所以在处理 字符串表示的日期时间 时要非常小心地约定好表示格式;

  • 与日期时间关联的时区,如果不指明时区,时间的表示就无意义,但很多软件系统里的时间是不管时区的,一旦需要跨时区使用就会带来一大堆麻烦,比如你带着你的手机出国,手机自动切换到目的地时区,然后你拍的照片、发出的邮件都是目的地的时间,如果不做处理,当你回到常居地这些照片和邮件的时间就错了;

  • 和日历有关的一系列处理,比如今天星期几?去年第二个月有多少天、最后一天是星期几?从现在开始加上 10000 天是什么日子?通常会有一个专门的日历模块来处理这类问题。

由于历史原因,日期时间的处理也有很多的坑(和梗),不过目前比较新的编程语言都提供了很完备的解决方案,我们以后会陆陆续续的碰到和学会,这里我们先重点介绍下 字符串表示的日期时间 数据和相关的处理。

4.1 日期时间 => 字符串

# datetime 类型在 datetime 包中,使用前需要先引入
from datetime import datetime

# 使用 datetime 类型的 now() 方法来获取当前时间
t = datetime.now()

现在 datetime 类型的变量 t 里面保存了上述代码运行时的时间信息,包括年、月、日、时、分、秒、微秒和时区,我们可以方便的获取这些分量:

print(t)
print(t.year)
print(t.month)
print(t.day)
print(t.hour)
print(t.minute)
print(t.second)
print(t.microsecond)
print(t.tzinfo)

可以看到这里时区信息(tzinfo)输出为 None,因为创建 t 时我们没有提供时区,我们可以在调用 now() 的时候传入一个时区参数,也可以不带参数调用 astimezone() 方法来给时间加上操作系统设定的本地时区:

t = datetime.now().astimezone()
print(t)
print(t.tzinfo)

datetime 模块里不只有 datetime 这个类型,如果我们只关心日期可以用 date 类型,如果只关心时间可以使用 time 类型。官方有一篇详细的文档可以参考。

有。就是 datetime 等类型提供的 strftime() 方法。这个方法让我们可以用指定格式把日期时间数据转换为字符串输出。

strftime() 需要一个参数,这个参数指定输出字符串的 “时间格式”,这个格式里是各种 % 打头的标志,每个标志代表一个 日期时间分量 及其格式。时间格式里除了 % 打头的标志以外都会原样输出,所以可以用我们喜欢的任何字符。

如果 strftime() 输出的字符串只是用来展示,那么格式相对自由,规范美观就好;如果用来保存数据(比如存到数据库里),那么格式就要非常严谨,确保以后读出来的时候还能正确解析。

4.2 字符串 => 日期时间

Python 提供许多方法来构造一个日期时间类型的变量,上面看到的 now() 是第一类,即获取当前时间;第二类是用前面介绍过的 Unix timestamp 来构造一个时间,这主要是为了兼容使用 Unix timestamp 的系统和库;第三类就是读取和解析一个表示日期时间的字符串,我们下面介绍主要方法 strptime()strptime() 可以看做 strftime() 的反向操作,使用一样的时间格式描述。

在绝大多数情况下,strptime() 都能很好的根据时间格式描述去 “套” 输入的字符串,然后把里面对应的年月日时分秒取出来,然后构造出一个对应的 datetime 类型变量。为了确保 用字符串表示的日期时间数据 能够在各种数据库和程序中都被正确理解和处理,专门有一个 ISO 标准规定了大家 “最好” 都采用的时间格式是怎样的,这就是 ISO 8601 标准,这一标准采用的 日期时间格式描述 大致是这样子的:

2019-07-17T12:38:24.091911+08:00

从左到右分三段:

  • 日期:YYYY-MM-DD,年月日之间用短横线隔开;
  • 时间:HH:MM:SS.ffffff,最多到微秒;日期和时间之间用一个大写的 'T' 作为分隔符;
  • 时区:通过与标准时的偏移量来表示时区,比如 +08:00 就是东八区,也就是我国采用的时区。

除了日期,后面的部分都是可选的,可以有也可以没有。

Python 通过 isoformat()fromisoformat() 两个方法来支持这个标准。

t = datetime.now().astimezone() # 生成一个 datetime 类型的数据

iso_str = t.isoformat() # 使用 isoformat() 方法把该数据 转成 ISO 标准的字符串
t_from_iso_str = datetime.fromisoformat(iso_str) # 通过 datetime.fromisoformat() 方法将 ISO 标准的字符串 转变成 datetime 类型的数据

ISO 8601 标准避免了各自定义不一样的 时间格式描述,如果我们处理的 日期时间字符串 是用于数据保存和数据交换,采用这个标准是最简单和保险的方式。

5. 字符串与自定义格式化数据

除了数值、日期时间等通用基础数据类型,我们还经常用字符串来处理和记录一些 自定义的格式化数据,比如身份证号码、电话号码、Email 地址等。

这些数据的特点是:

  • 由字母、数字和一些特定的符号组成,长度不会太长;
  • 有明确的格式要求;
  • 一般会在输入时检验其是否符合格式要求。

所以,对这类数据我们经常做的是:格式校验、搜索和替换。

在处理这类数据时有个绝佳的工具 “正则表达式(regular expression)”,正则表达式是处理文本数据的利器,学习起来有一定的门槛,所以经常吓到很多人(其中包括一些工作多年的程序员),但其实只要方法得当,学会用正则表达式处理一些常见情况并没有那么难,也是越早学会越早受益的典范。我们在附录中有一篇 正则表达式入门 可作为学习的起点。

希望系统学习 正则表达式 的话可以使用余晟老师编写的《正则指引》一书。另外 Python 官方文档中的 正则表达式指引 也不错。

5.1 手机号码

手机号码是我们经常需要处理的数据,为了简单起见,我们限定为中国的手机号,暂不考虑国际区号,那么手机号码应该是一个 3 或 4 位的运营商段码(绝大部分是 3 位),再加 8 位号码,一共 11 或 12 位数字。

我们如果不考虑新加入的两个四位的号段,也不做特别严谨的检查,手机号码的正则规则大概是这样的:

import re

def is_valid_cellphone(s):
    pattern = re.compile(r'^[1]([3-9])[0-9]{9}$')
    if pattern.match(s):
        return True
    else:
        return False

上面的代码非常好懂:首先引入 Python 的正则表达式库,然后编译一个我们写好的 正则规则生成 pattern,然后用这个 pattern 去匹配输入的字符串,如果匹配成功返回 True,否则返回 False

这个正则规则也很好懂:

  • ^ 表示开始,$ 表示结束;
  • [1] 表示第一个字符必须是 1;
  • ([3-9]) 表示接下来是 3-9 这些数字中的一个,用小括号括起来表示这是一个 group,选出来我们以后可以用(比如通过号段判断运营商);
  • [0-9]{9} 表示接下去是 0-9 的数字中的一个,重复 9 次。

学习正则最好的办法就是看别人写好的规则,尝试去理解,如果理解不了,这里有个秘笈:有不少正则规则可视化工具,可以对输入的正则规则给出可视化的解析,比如 Regexper,还有 Debuggex,都可以。

如果要非常精确地匹配我国现有的号段规则,这个正则规则可能就有点长了,大致是这个样子的:

^[1](([3][0-9])|([4][5-9])|([5][0-3,5-9])|([6][5,6])|([7][0-8])|([8][0-9])|([9][1,8,9]))[0-9]{8}$

如果把它贴到 Regexper 里,可以看到结构其实也不复杂,主要都在罗列号段 1xx 里的各种情况,这些情况都用 () 分组,这样匹配完了之后我们可以知道匹配到了哪种情况,方便我们根据号段来识别运营商。

手机号码的号段是个会时不时变化的规则,我们如果要编写和维护一个函数,检查手机号码是不是合法、是哪个运营商的,就要时不时更新规则。如果要处理其他国家的手机,那就更加复杂,我们把这些留给大家自己去练习。

5.2 Email

我们每个人都有电子邮件地址,基本上就是 someone@any.com 这个样子,与直觉相反,用正则表达式来检查 Email 地址是不是合法可不简单。

Email 服务相关的规范相当古老,由 IETF 制订的一系列标准定义,由于历史悠久又涉及到域名规则,我们很难写出一个完美的正则规则来检查 Email 地址,好在大部分时候我们不需要那么完美,一般实际应用中只要保证基本合规就可以了,实际向某个地址发送邮件之后还是要做 “如果这个地址收不到信怎么办” 的处理。

幸运的是,由于 Email 校验这个问题太经典也太经常被提出来了,有人甚至专门做了个网站,叫 Email Address Regular Expression That 99.99% Works,根据这个网站提供的正则规则,我们可以写出下面的这个函数:

def is_valid_email(s):
    pattern = re.compile(r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)')
    if pattern.match(s):
        return True
    else:
        return False

利用这个函数我们可以方便地检查用户输入的是不是一个合法的 Email 地址。

while True:
    email = input('Please enter your Email: ')
    if is_valid_email(email):
        break
    else:
        print('It\'s not a valid Email address. Please try again.')

print(f'Your Email address is \'{email}\'.')

小结

  • 字符串是用来表示各种数据的利器,重点在于约定好表示的格式,可以把数据按格式组合成字符串,也可以按照格式 parse 字符串得到里面的数据;
  • Parse 字符串时处理各种意外情况是一个关键;
  • 了解 Python 中数值类型和日期时间类型,及其与字符串之间来回转换的方法;
  • 了解 用正则表达式处理特定格式字符串的 基本方法。

Logging

2020-03-15 01:05:57 中间傻逼地断学了 2020-03-05 17:40:48 initialize