Python的文本和位元組序列

一、字符串的表示和存儲

字符串是字符的序列,每個字符都有有一個數字作為標識,同時會有一個將標識轉換為存儲位元組的編碼方案;

s = 'hello world python'
for c in s:
  print(c, end=' ')

h e l l o w o r l d p y t h o n

ACSII為協議內的每個字符分別對應一個數字,然後以這個數字的二進制形式存儲到計算機;

s = 'hello world python'

for c in s: 
  num = ord(c)
  print(num, format(num, 'b'))
104 1101000
101 1100101
108 1101100
108 1101100
111 1101111
32 100000
119 1110111
111 1101111
114 1110010
108 1101100
100 1100100
32 100000
112 1110000
121 1111001
116 1110100
104 1101000
111 1101111
110 1101110

ACSII協議覆蓋的字符十分有限,使用一個位元組就可以保存,這也是其比較簡單的根源;

s = b'é'
  File "<ipython-input-19-b82fcf157fe5>", line 1
    s = b'é'
       ^
SyntaxError: bytes can only contain ASCII literal characters.

unicode標準為每個字符制定一個數字作為code point;

s = 'è ç í'
for c in s:
  print(ord(c))
232
32
231
32
237

unicode支持大量的字符,需要使用多個位元組來存儲,這就涉及到位元組的大小端、空間佔用及與ACSII的兼容性問題;

UTF-32編碼方案直接使用4個位元組來承載code poin的二進制形式,涉及大小端問題,比較浪費空間,使用較少;

s = 'èçí'

for b in s.encode('utf_32be'):
  print(hex(b), end=' ')

print()
for b in s.encode('utf_32le'):
  print(hex(b), end=' ')

print()
for b in s.encode('utf_32'):
  print(hex(b), end=' ')
0x0 0x0 0x0 0xe8 0x0 0x0 0x0 0xe7 0x0 0x0 0x0 0xed 
0xe8 0x0 0x0 0x0 0xe7 0x0 0x0 0x0 0xed 0x0 0x0 0x0 
0xff 0xfe 0x0 0x0 0xe8 0x0 0x0 0x0 0xe7 0x0 0x0 0x0 0xed 0x0 0x0 0x0 

UTF-16編碼方案根據前兩個位元組的範圍來確定使用兩個位元組還是四個位元組,雖然比UTF-32節省空間,但是使用也比較少;

s = 'èçí'

for b in s.encode('utf_16be'):
  print(hex(b), end=' ')

print()
for b in s.encode('utf_16le'):
  print(hex(b), end=' ')

print()
for b in s.encode('utf_16'):
  print(hex(b), end=' ')
0x0 0xe8 0x0 0xe7 0x0 0xed 
0xe8 0x0 0xe7 0x0 0xed 0x0 
0xff 0xfe 0xe8 0x0 0xe7 0x0 0xed 0x0 

UTF-8也使用變長位元組,每個字符使用的位元組個數與其Unicode編號的大小有關,編號小的使用的位元組就少,編號大的使用的位元組就多,使用的位元組個數為1~4不等;

s = 'èçí'

for b in s.encode('utf_8'):
  print(hex(b), end=' ')
0xc3 0xa8 0xc3 0xa7 0xc3 0xad 

utf-16和utf-32編碼方案默認生成的位元組序列會添加BOM(byte-order mark)即\xff\xfe,指明編碼的時候使用Interl CPU小位元組序。

二、位元組數組
bytes和bytearray的元素都是介於0-255之間的整數,但是通過字符編碼方案也可以存儲任何的字符串;位元組數組切片還是對應的位元組數組;
位元組數組可以直接顯示ASCII字符;

s = 'helloèçí'
b_arr = bytes(s, 'utf_8')
print(type(b_arr))
print(type(b_arr))
for b in b_arr:
  print(b, end=' ')

print()
print('element of bytes is int number', b_arr[0])

print('splice of bytes is bytes',end = ' ' )
b_arr_splice = b_arr[:1]
print(b_arr_splice)

num_b_arr = bytes([299])
<class 'bytes'>
b'hello\xc3\xa8\xc3\xa7\xc3\xad'
104 101 108 108 111 195 168 195 167 195 173 
element of bytes is int number 104
splice of bytes is bytes b'h'
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-61-b8f064f91cf5> in <module>()
     13 print(b_arr_splice)
     14 
---> 15 num_b_arr = bytes([299])

ValueError: bytes must be in range(0, 256)

struct模塊提供了一些函數,把打包的位元組序列轉換成不同類型字段組成的元組,還有一些函數用於執行反向轉換,把元組轉換成打包的位元組序列。struct模塊能處理bytes、bytearray和memoryview對象。

import struct
record_format = 'hd4s'
pack_bytes = struct.pack(record_format, 7 , 3.14,b'gbye')
print(type(pack_bytes))
print(pack_bytes)
with open('struct.b', 'wb') as fp:
  fp.write(pack_bytes)

record_size = struct.calcsize(record_format)
with open('struct.b', 'rb') as fp:
  record_bs = fp.read(record_size)
  print(struct.unpack(record_format, record_bs))

三、不要依賴默認編碼

讀寫文本文件的時候最好要顯示的指定編碼方案,防止編碼方案不匹配出現亂碼或者錯誤;

open('cafe.txt', 'w', encoding='utf-8').write('café')

fp = open('cafe.txt')
print(fp)
print(fp.read())

由於Linux的默認編碼是UTF-8,所以運行結果正常

<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='UTF-8'>
café

但是在windows 10上執行就不這麼幸運了,我們可以看到IO的默認編碼方案是cp936

<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp936'>
caf茅

在Linux和windows上分別執行以下探測默認編碼方案的代碼

import sys, locale
expressions = '''
  locale.getpreferredencoding()
  type(my_file)
  my_file.encoding
  sys.stdout.isatty()
  sys.stdout.encoding
  sys.stdin.isatty()
  sys.stdin.encoding
  sys.stderr.isatty()
  sys.stderr.encoding
  sys.getdefaultencoding()
  sys.getfilesystemencoding()
'''

with open('encoding', 'w') as my_file:
  for expression in expressions.split():
    value = eval(expression)
    print(expression.rjust(30), '->', repr(value))

在Ubuntu上執行,可以看到輸出的都是UTF-8;

 locale.getpreferredencoding() -> 'UTF-8'
                 type(my_file) -> <class '_io.TextIOWrapper'>
              my_file.encoding -> 'UTF-8'
           sys.stdout.isatty() -> True
           sys.stdout.encoding -> 'utf-8'
            sys.stdin.isatty() -> True
            sys.stdin.encoding -> 'utf-8'
           sys.stderr.isatty() -> True
           sys.stderr.encoding -> 'utf-8'
      sys.getdefaultencoding() -> 'utf-8'
   sys.getfilesystemencoding() -> 'utf-8'

在windows 10上執行,locale.getpreferredencoding()和my_file的編碼都是cp936;

locale.getpreferredencoding() -> 'cp936'
                 type(my_file) -> <class '_io.TextIOWrapper'>
              my_file.encoding -> 'cp936'
           sys.stdout.isatty() -> True
           sys.stdout.encoding -> 'utf-8'
            sys.stdin.isatty() -> True
            sys.stdin.encoding -> 'utf-8'
           sys.stderr.isatty() -> True
           sys.stderr.encoding -> 'utf-8'
      sys.getdefaultencoding() -> 'utf-8'
   sys.getfilesystemencoding() -> 'utf-8'

如果沒有指定編碼方案,操作文本文件的時候默認使用locale.getpreferredencoding(),在windows10上將python的執行結果重定向到文件,可以看到sys.stdout.encoding變成了cp936;

 locale.getpreferredencoding() -> 'cp936'
                 type(my_file) -> <class '_io.TextIOWrapper'>
              my_file.encoding -> 'cp936'
           sys.stdout.isatty() -> False
           sys.stdout.encoding -> 'cp936'
            sys.stdin.isatty() -> True
            sys.stdin.encoding -> 'utf-8'
           sys.stderr.isatty() -> True
           sys.stderr.encoding -> 'utf-8'
      sys.getdefaultencoding() -> 'utf-8'
   sys.getfilesystemencoding() -> 'utf-8'

python使用sys.getdefaultencoding()進行二進制數據與字符串之間的轉換;
sys.getfilesystemencoding( )用於編解碼文件名(不是文件內容)。把字符串參數作為文件名傳給open( )函數時就會使用它;

四、規範化字符串之後進行比較

因為Unicode有組合字符(變音符號和附加到前一個字符上的記號,打印時作為一個整體),所以字符串比較起來很複雜。

# 同樣的一個字符會有不同的構成方式
s1 = 'café'
s2 = 'cafe\u0301'
print((s1, s2))
print((len(s1), len(s2)))
print(s1 == s2)
('café', 'café')
(4, 5)
False

U+0301是COMBINING ACUTE ACCENT,加在「e」後面得到「é」。在Unicode標準中,’é’和’e\u0301’這樣的序列叫「標準等價物」(canonical equivalent),應用程序應該把它們視作相同的字符。但是,Python看到的是不同的碼位序列,因此判定二者不相等。

Python中unicodedata.normalize函數提供的Unicode規範化。這個函數的第一個參數是這4個字符串中的一個:’NFC’、’NFD’、’NFKC’和’NFKD’。NFC(Normalization Form C)使用最少的碼位構成等價的字符串,而NFD把組合字符分解成基字符和單獨的組合字符。這兩種規範化方式都能讓比較行為符合預期:

# normalize字符串再進行比較
from unicodedata import normalize
s1 = 'café'
s2 = 'cafe\u0301'
print((s1, s2))
print((len(s1), len(s2)))
print(s1 == s2)

s1_nfc_nor = normalize('NFC', s1)
s2_nfc_nor = normalize('NFC', s2)
print((s1_nfc_nor, s2_nfc_nor))
print((len(s1_nfc_nor), len(s2_nfc_nor)))
print(s1_nfc_nor == s2_nfc_nor)

s1_nfd_nor = normalize('NFD', s1)
s2_nfd_nor = normalize('NFD', s2)
print((s1_nfd_nor, s2_nfd_nor))
print((len(s1_nfd_nor), len(s2_nfd_nor)))
print(s1_nfd_nor == s2_nfd_nor)

# ('café', 'café')
# (4, 5)
# False
# ('café', 'café')
# (4, 4)
# True
# ('café', 'café')
# (5, 5)
# True

在另外兩個規範化形式(NFKC和NFKD)的首字母縮略詞中,字母K表示「compatibility」(兼容性)。這兩種是較嚴格的規範化形式,對「兼容字符」有影響。雖然Unicode的目標是為各個字符提供「規範的」碼位,但是為了兼容現有的標準,有些字符會出現多次。例如,雖然希臘字母表中有「μ」這個字母(碼位是U+03BC,GREEK SMALL LETTER MU),但是Unicode還是加入了微符號’µ’(U+00B5),以便與latin1相互轉換。因此,微符號是一個「兼容字符」。

# NFKC的規範化
from unicodedata import normalize, name
half = '½'
print(len(half))
print(hex(ord(half)))
half_nor = normalize('NFKC', half)
print(half_nor)
print(type(half_nor))
print(len(half_nor))
for c in half_nor:
  print(hex(ord(c)), end=' ')

print()
four_squared = '4²'
four_squared_no = normalize('NFKC', four_squared)
print(four_squared_no)

micro = 'µ'
micro_nor = normalize('NFKC', micro)
print(micro_nor)
print(ord(micro), ord(micro_nor))
print(name(micro), name(micro_nor))

# 1
# 0xbd
# 1⁄2
# <class 'str'>
# 3
# 0x31 0x2044 0x32 
# 42
# μ
# 181 956
# MICRO SIGN GREEK SMALL LETTER MU

使用’1/2’替代’½’可以接受,微符號也確實是小寫的希臘字母’µ’,但是把’4²’轉換成’42’就改變原意了。某些應用程序可以把’4²’保存為’42‘,但是normalize函數對格式一無所知。因此,NFKC或NFKD可能會損失或曲解信息。

大小寫摺疊其實就是把所有文本變成小寫,再做些其他轉換。這個功能由str.casefold( )方法(Python 3.3新增)支持。對於只包含latin1字符的字符串s,s.casefold( )得到的結果與s.lower( )一樣,唯有兩個例外:微符號’µ’會變成小寫的希臘字母「μ」(在多數字體中二者看起來一樣);德語Eszett(「sharp s」,ß)會變成「ss」。

# 大小寫摺疊
micro = 'µ'
print(name(micro))
micro_cf = micro.casefold()
print(name(micro_cf))
print((micro, micro_cf))
eszett = 'ß'
print(name(eszett))
eszett_cf = eszett.casefold()
print((eszett, eszett_cf))

# MICRO SIGN
# GREEK SMALL LETTER MU
# ('µ', 'μ')
# LATIN SMALL LETTER SHARP S
# ('ß', 'ss')

Google搜索涉及很多技術,其中一個顯然是忽略變音符號(如重音符、下加符等),至少在某些情況下會這麼做。去掉變音符號不是正確的規範化方式,因為這往往會改變詞的意思,而且可能誤判搜索結果。但是對現實生活卻有所幫助:人們有時很懶,或者不知道怎麼正確使用變音符號,而且拼寫規則會隨時間變化,因此實際語言中的重音經常變來變去。

# 極端規範化,去掉變音符號
import unicodedata
import string
def shave_marks(txt):
  txt_nor = normalize('NFD', txt)
  txt_shaved = ''.join(c for c in txt_nor if not unicodedata.combining(c))
  return normalize('NFC', txt_shaved)

order = 'è ç í'
print(shave_marks(order))

greek = 'έ é'
print(shave_marks(greek))


def shave_marks_latin(txt):
  txt_nor = normalize('NFD', txt)
  latin_base = False
  keep = []
  for c in txt_nor:
    if unicodedata.combining(c) and latin_base:
      continue;
    keep.append(c)
    if not  unicodedata.combining(c):
      latin_base = c in string.ascii_letters
    
  shaved = ''.join(keep)
  return normalize('NFC', shaved)

print(shave_marks_latin(order))
print(shave_marks_latin(greek))


# e c i
# ε e
# e c i
# έ e

代碼