Python的文字和位元組序列

無風聽海發表於2021-04-22

一、字串的表示和儲存

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

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

程式碼

相關文章