在Python中存儲(chǔ)字符串
前言:
在這篇Python字符集和字符編碼中我們提到了unicode,該字符集對(duì)世界上的文字進(jìn)行了系統(tǒng)的整理,讓計(jì)算機(jī)可以用統(tǒng)一的方式處理文本,而且目前已經(jīng)支持超過(guò)13萬(wàn)個(gè)字符,天然地支持多國(guó)語(yǔ)言。
所以不管什么文字,都可以用一個(gè)unicode來(lái)表示。
但是問(wèn)題來(lái)了,unicode能表示這么多的字符,那么占用的內(nèi)存一定不低吧。的確,根據(jù)當(dāng)時(shí)的編碼,一個(gè)unicode字符最高會(huì)占用到4字節(jié)。但是對(duì)于西方人來(lái)說(shuō),明明一個(gè)字符就夠用了,為啥需要那么多。
于是又出現(xiàn)了utf-8,它是為unicode提供的一個(gè)新的編碼規(guī)則,具有可變長(zhǎng)的功能。不同種類的字符占用的大小不同,比如英文字符使用一個(gè)字節(jié)存儲(chǔ),漢字使用3個(gè)字節(jié)存儲(chǔ),Emoji 使用4個(gè)字節(jié)存儲(chǔ)。
但Python在表示unicode字符串時(shí),使用的卻不是utf-8編碼,至于原因我們下面來(lái)分析一下。
unicode 的三種編碼
從Python3開(kāi)始,字符串使用的是Unicode。而根據(jù)編碼的不同,Unicode的每個(gè)字符最大可以占到4字節(jié),從內(nèi)存的角度來(lái)說(shuō), 這種編碼有時(shí)會(huì)比較昂貴。
為了減少內(nèi)存消耗并且提高性能,Python的內(nèi)部使用了三種編碼方式來(lái)表示Unicode:
- Latin-1 編碼:每個(gè)字符一字節(jié);
- UCS2 編碼:每個(gè)字符兩字節(jié);
- UCS4 編碼:每個(gè)字符四字節(jié);
在Python編程中,所有字符串的行為都是一致的,而且大多數(shù)時(shí)間我們都沒(méi)有注意到差異。然而在處理大文本的時(shí)候,這種差異就會(huì)變得異常顯著、甚至有些讓人出乎意料。
為了看到內(nèi)部表示的差異,我們使用sys.getsizeof函數(shù),查看一個(gè)對(duì)象所占的字節(jié)數(shù)。
import sys print(sys.getsizeof("a")) # 50 print(sys.getsizeof("憨")) # 76 print(sys.getsizeof("??")) # 80
我們看到都是一個(gè)字符,但是它們占用的內(nèi)存卻是不一樣的。因?yàn)镻ython面對(duì)不同的字符會(huì)采用不同的編碼,進(jìn)而導(dǎo)致大小不同。
但需要注意的是:Python的每一個(gè)字符串都需要額外占用49-80字節(jié),因?yàn)橐鎯?chǔ)一些額外的信息,比如:公共的頭部、哈希、長(zhǎng)度、字節(jié)長(zhǎng)度、編碼類型等等。
import sys # 對(duì)于ASCII字符,一個(gè)占1字節(jié),顯然此時(shí)編碼是Latin-1編碼 print(sys.getsizeof("ab") - sys.getsizeof("a")) # 1 # 對(duì)于漢字,日文等等,一個(gè)占用2字節(jié),此時(shí)是UCS2編碼 print(sys.getsizeof("憨憨") - sys.getsizeof("憨")) # 2 print(sys.getsizeof("です") - sys.getsizeof("で")) # 2 # 像emoji,則是一個(gè)占4字節(jié) ,此時(shí)是UCS4編碼 print(sys.getsizeof("????") - sys.getsizeof("??")) # 4
而采用不同的編碼,那么底層結(jié)構(gòu)體實(shí)例的額外部分也會(huì)占用不同大小的內(nèi)存。
如果編碼是Latin-1,那么這個(gè)結(jié)構(gòu)體實(shí)例額外的部分會(huì)占49個(gè)字節(jié);編碼是UCS2,占74個(gè)字節(jié);編碼是UCS4,占76個(gè)字節(jié)。然后字符串所占的字節(jié)數(shù)就等于:額外的部分 + 字符個(gè)數(shù) * 單個(gè)字符所占的字節(jié)。
import sys # 所以一個(gè)空字符串占用49個(gè)字節(jié) # 此時(shí)會(huì)采用占用內(nèi)存最小的Latin-1編碼 print(sys.getsizeof("")) # 49 # 此時(shí)使用UCS2 print(sys.getsizeof("憨") - 2) # 74 # UCS4 print(sys.getsizeof("??") - 4) # 76
為什么不使用utf-8編碼
上面提到的三種編碼,是Python在底層所使用的,但我們知道unicode還有一個(gè)utf-8編碼,那Python為啥不用呢?
先來(lái)拋出一個(gè)問(wèn)題:首先我們知道Python支持通過(guò)索引查找一個(gè)字符串指定位置的字符,而且Python默認(rèn)是以字符為單位的,不是字節(jié)(我們后面還會(huì)提),比如s[2]搜索的就是字符串s中的第3個(gè)字符。
s = "古明地覺(jué)" print(s[2]) # 地
那么問(wèn)題來(lái)了,我們知道通過(guò)索引查找字符串的某個(gè)字符,時(shí)間復(fù)雜度為O(1),那么Python是怎么通過(guò)索引瞬間定位到指定字符的呢?
顯然是通過(guò)指針的偏移,用索引乘上每個(gè)字符占的字節(jié)數(shù),得到偏移量,然后從頭部向后偏移指定數(shù)量的字節(jié)即可,這樣就能在定位到指定字符的同時(shí)還保證時(shí)間復(fù)雜度為O(1)。
但是這需要一個(gè)前提:字符串中每個(gè)字符所占的大小必須是相同的,如果字符占的大小不同,比如有的占1字節(jié)、有的占3字節(jié),顯然就無(wú)法通過(guò)指針偏移的方式了。這個(gè)時(shí)候若還想準(zhǔn)確定位的話,只能按順序?qū)λ凶址贾饌€(gè)掃描,但這樣的話時(shí)間復(fù)雜度肯定不是O(1),而是O(n)
我們以Go為例,Go的字符串默認(rèn)就是使用的utf-8編碼:
package main import ( "fmt" ) func main() { s := "古明地覺(jué)" fmt.Println(s[2]) // 164 fmt.Println(string(s[2])) // ¤ }
驚了,我們看到打印的并不是我們希望的結(jié)果。因?yàn)镚o底層使用的是utf-8編碼,不同的字符可能會(huì)占用不同的字節(jié)。但是Go通過(guò)索引定位的時(shí)候,時(shí)間復(fù)雜度也是O(1),所以定位的時(shí)候是以字節(jié)為單位、而不是字符。在獲取的時(shí)候也只會(huì)獲取一個(gè)字節(jié),而不是一個(gè)字符。
所以s[2]在Go里面指的是第3個(gè)字節(jié),而不是第3個(gè)字符,而漢字在utf-8編碼下占3個(gè)字節(jié),所以s[2]指的就是漢字古的第三個(gè)字節(jié)。我們看到打印的時(shí)候,該字節(jié)存的值為164。
s = "古明地覺(jué)" print(s.encode("utf-8")[2]) # 164
這就是采用utf-8編碼帶來(lái)的弊端,它無(wú)法讓我們以O(shè)(1)的時(shí)間復(fù)雜度去準(zhǔn)確地定位字符,盡管它在存儲(chǔ)的時(shí)候更加的省內(nèi)存。
Latin-1、UCS2、UCS4該使用哪一種?
我們說(shuō)Python會(huì)使用3種編碼來(lái)表示unicode,所占字節(jié)大小分別是1、2、4字節(jié)。
因此Python在創(chuàng)建字符串的時(shí)候,會(huì)先掃描,嘗試使用占字節(jié)數(shù)最少的Latin-1編碼存儲(chǔ),但是范圍肯定有限。如果發(fā)現(xiàn)了存儲(chǔ)不下的字符,只能改變編碼,使用UCS2,繼續(xù)掃描。但是又發(fā)現(xiàn)了新的字符,這個(gè)字符UCS2也無(wú)法存儲(chǔ),因?yàn)閮蓚€(gè)字節(jié)最多存儲(chǔ)65535個(gè)不同的字符,所以會(huì)再次改變編碼,使用UCS4。UCS4占四個(gè)字節(jié),肯定能存下了。
一旦改變編碼,字符串中的所有字符都會(huì)使用同樣的編碼,因?yàn)樗鼈儾痪邆淇勺冮L(zhǎng)功能。比如這個(gè)字符串:"hello古明地覺(jué)",肯定都會(huì)使用UCS2,不存在說(shuō)hello使用Latin1,古明地覺(jué)使用UCS2,因?yàn)橐粋€(gè)字符串只能有一個(gè)編碼。
當(dāng)通過(guò)索引獲取的時(shí)候,會(huì)將索引乘上每個(gè)字符占的字節(jié)數(shù),這樣就能跳到準(zhǔn)確位置上,因?yàn)樽址锩娴乃凶址加玫淖止?jié)都是一樣的,然后獲取的時(shí)候也會(huì)獲取指定的字節(jié)數(shù)。比如:使用UCS2編碼,那么定位到某個(gè)字符的時(shí)候,會(huì)取兩個(gè)字節(jié),這樣才能表示一個(gè)完整的字符。
import sys # 此時(shí)全部是ascii字符,那么Latin-1編碼可以存儲(chǔ) # 所以結(jié)構(gòu)體實(shí)例額外的部分占49個(gè)字節(jié) s1 = "hello" # 有5個(gè)字符,一個(gè)字符一個(gè)字節(jié),所以加一起是54個(gè)字節(jié) print(sys.getsizeof(s1)) # 54 # 出現(xiàn)了漢字,那么Latin-1肯定存不下,于是使用UCS2 # 所以此時(shí)結(jié)構(gòu)體實(shí)例額外的部分占74個(gè)字節(jié) # 但是別忘了此時(shí)的英文字符也是ucs2,所以也是一個(gè)字符兩字節(jié) s2 = "hello憨" # 6個(gè)字符,74 + 6 * 2 = 86 print(sys.getsizeof(s2)) # 86 # 這個(gè)牛逼了,ucs2也存不下,只能ucs4存儲(chǔ)了 # 所以結(jié)構(gòu)體實(shí)例額外的部分占76個(gè)字節(jié) s3 = "hello憨??" # 此時(shí)所有字符一個(gè)占4字節(jié),7個(gè)字符 # 76 + 7 * 4 = 104 print(sys.getsizeof(s3)) # 104
除此之外,我們?cè)倥e一個(gè)例子更形象地證明這個(gè)現(xiàn)象。
import sys s1 = "a" * 1000 s2 = "a" * 1000 + "??" # 我們看到s2只比s1多了一個(gè)字符 # 但是兩者占的內(nèi)存,s2卻將近是s1的四倍。 print(sys.getsizeof(s1), sys.getsizeof(s2)) # 1049 4080
我們知道s2和s1的差別只是s2比s1多了一個(gè)字符,但就是這么一個(gè)字符導(dǎo)致s2比s1多占了3031個(gè)字節(jié)。然而這3031個(gè)字節(jié)不可能是多出來(lái)的字符所占的大小,什么字符一個(gè)會(huì)占到三千多個(gè)字節(jié),這是不可能的。
盡管如此,但它也是罪魁禍?zhǔn)祝贿^(guò)前面的1000個(gè)字符也是共犯。我們說(shuō)Python會(huì)根據(jù)字符串選擇不同的編碼,s1全部是ascii字符,所以Latin1能存下,因此一個(gè)字符只占一個(gè)字節(jié)。所以大小就是49 + 1000 = 1049。
但是對(duì)于s2,Python發(fā)現(xiàn)前1000個(gè)字符Latin1能存下,不幸的是最后一個(gè)字符存不下,于是只能使用UCS4。而字符串的所有字符只能有一個(gè)編碼,為了保證索引查找的時(shí)間復(fù)雜度為O(1),前面一個(gè)字節(jié)就能存下的字符,也需要用4字節(jié)來(lái)存儲(chǔ)。這是Python的設(shè)計(jì)策略。
而我們說(shuō)使用UCS4,結(jié)構(gòu)體額外的部分會(huì)占76個(gè)字節(jié),因此s2的大小就是:76 + 1001 * 4 = 4080
print(sys.getsizeof("爺?shù)那啻夯貋?lái)了")) # 88 print(sys.getsizeof("??的青春回來(lái)了")) # 104
字符數(shù)量相同但是占用內(nèi)存大小不同,相信原因你肯定能分析出來(lái)。
所以如果字符串中的所有字符都是ASCII字符,則使用1字節(jié)Latin1對(duì)其進(jìn)行編碼?;旧?,Latin1能表示前256個(gè)Unicode字符,它支持多種拉丁語(yǔ),如英語(yǔ)、瑞典語(yǔ)、意大利語(yǔ)、挪威語(yǔ)。但是它們不能存儲(chǔ)非拉丁語(yǔ)言,比如漢語(yǔ)、日語(yǔ)、希伯來(lái)語(yǔ)、西里爾語(yǔ)。這是因?yàn)樗鼈兊拇a點(diǎn)(數(shù)字索引)定義在1字節(jié)(0-255)范圍之外。
大多數(shù)流行的自然語(yǔ)言都可以采用2字節(jié)(UCS2)編碼,但當(dāng)字符串包含特殊符號(hào)、emoji或稀有語(yǔ)言時(shí),則使用4字節(jié)(UCS4)編碼。Unicode標(biāo)準(zhǔn)有將近300個(gè)塊(范圍),你可以在0XFFFF塊之后找到4字節(jié)塊。
假設(shè)我們有一個(gè)10G的ASCII文本,我們想把它加載到內(nèi)存中,但如果我們?cè)谖谋局胁迦胍粋€(gè)表情符號(hào),那么字符串的大小將增加4倍。這是一個(gè)巨大的差異,你可能會(huì)在實(shí)踐當(dāng)中遇到,比如處理NLP問(wèn)題。
print(ord("a")) # 97 print(ord("憨")) # 25000 print(ord("??")) # 128187
所以最著名和最流行的Unicode編碼都是utf-8,但是Python不在內(nèi)部使用它,而是使用Latin1、UCS2、UCS4。至于原因我們上面已經(jīng)解釋的很清楚了,主要是Python的索引是基于字符,而不是字節(jié)。
當(dāng)一個(gè)字符串使用utf-8編碼存儲(chǔ)時(shí),每個(gè)字符會(huì)根據(jù)自身選擇一個(gè)合適的大小。這是一種存儲(chǔ)效率很高的編碼,但是它有一個(gè)明顯的缺點(diǎn)。由于每個(gè)字符的字節(jié)長(zhǎng)度可能不同,就導(dǎo)致無(wú)法按照索引瞬間定位到單個(gè)字符,即便能定位,也無(wú)法定位準(zhǔn)確。如果想準(zhǔn),那么只能逐個(gè)掃描所有字符。
假設(shè)要對(duì)使用utf-8編碼的字符串執(zhí)行一個(gè)簡(jiǎn)單的操作,比如s[5],就意味著Python需要掃描每一個(gè)字符,直到找到需要的字符,這樣效率是很低的。
但如果是固定長(zhǎng)度的編碼就沒(méi)有這樣的問(wèn)題,所以當(dāng)Latin 1存儲(chǔ)的hello,在和UCS2存儲(chǔ)的古明地覺(jué)組合之后,整體每一個(gè)字符都會(huì)向大的方向擴(kuò)展、變成了2字節(jié)。
這樣定位字符的時(shí)候,只需要將索引 * 2便可計(jì)算出偏移的字節(jié)數(shù)、然后跳轉(zhuǎn)該字節(jié)數(shù)即可。但如果原來(lái)的hello還是一個(gè)字節(jié)、而漢字是2字節(jié),那么只通過(guò)索引是不可能定位到準(zhǔn)確字符的,因?yàn)椴煌愋妥址拇笮〔煌仨氁獟呙枵麄€(gè)字符串才可以。但是掃描字符串,效率又比較低,所以Python內(nèi)部才會(huì)使用這個(gè)方法,而不是使用utf-8。
所以對(duì)于Go來(lái)講,如果想像Python一樣,那么需要這么做:
package main import ( "fmt" ) func main() { s := "hello古明地覺(jué)" //我們看到長(zhǎng)度為17, 因?yàn)樗褂胾tf-8編碼 fmt.Println(s, len(s)) // hello古明地覺(jué) 17 //如果想像Python一樣 //那么Go提供了一個(gè)rune,相當(dāng)于int32 //此時(shí)每個(gè)字符均使用4個(gè)字節(jié),所以長(zhǎng)度變成了9 r := []rune(s) fmt.Println(string(r), len(r)) // hello古明地覺(jué) 9 //雖然打印的內(nèi)容是一樣的,但是此時(shí)每個(gè)字符都使用4字節(jié)存儲(chǔ) //此時(shí)跳轉(zhuǎn)會(huì)和Python一樣偏移 5 * 4 個(gè)字節(jié) //然后獲取也會(huì)獲取4個(gè)字節(jié),因?yàn)橐粋€(gè)字符占4個(gè)字節(jié) fmt.Println(string(r[5])) //古 }
所以u(píng)tf-8編碼的unicode字符串里面的字符可能占用不同的字節(jié),顯然沒(méi)辦法實(shí)現(xiàn)當(dāng)前Python字符串的索引查找效果,因此Python沒(méi)有使用utf-8編碼。
Python的做法是讓字符串的所有字符都占用相同的字節(jié),先使用占用內(nèi)存最小的Latin1,不行的話再使用UCS2、UCS4,總之會(huì)確保每個(gè)字符占用的字節(jié)是一樣的。至于原因的話我們上面分析的很透徹了,因?yàn)闊o(wú)論是索引還是切片、還是計(jì)算長(zhǎng)度等等,都是基于字符來(lái)的,顯然這也符合人類的思維習(xí)慣。
小結(jié)
Python字符串的存儲(chǔ)策略,它并沒(méi)有使用最為流行的utf-8,歸根結(jié)底就在于這種編碼不適合Python的字符串。當(dāng)然,我們?cè)趯⒆址D(zhuǎn)成字節(jié)序列的時(shí)候,一般使用的都是utf-8編碼。
到此這篇關(guān)于在Python中存儲(chǔ)字符串的文章就介紹到這了,更多相關(guān)Python存儲(chǔ)字符串內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
python數(shù)學(xué)模塊(math/decimal模塊)
這篇文章主要介紹了python數(shù)學(xué)模塊(math/decimal模塊),文章圍繞主題展開(kāi)詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下2022-09-09pandas按若干個(gè)列的組合條件篩選數(shù)據(jù)的方法
下面小編就為大家分享一篇pandas按若干個(gè)列的組合條件篩選數(shù)據(jù)的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-04-04python指定寫(xiě)入文件時(shí)的編碼格式方法
今天小編就為大家分享一篇python指定寫(xiě)入文件時(shí)的編碼格式方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-06-06Python爬蟲(chóng)之PhantomJS和handless的使用詳解
這篇文章主要介紹了Python爬蟲(chóng)之PhantomJS和handless的使用詳解,PhantomJS是一個(gè)基于Webkit的headless瀏覽器,它會(huì)把網(wǎng)站加載到內(nèi)存并使用webkit來(lái)編譯解釋執(zhí)行頁(yè)面上的JavaScript代碼,由于不進(jìn)行css和gui渲染、不展示圖形界面,需要的朋友可以參考下2023-09-09使用python檢測(cè)網(wǎng)頁(yè)文本內(nèi)容屏幕上的坐標(biāo)
在 Web 開(kāi)發(fā)中,經(jīng)常需要對(duì)網(wǎng)頁(yè)上的文本內(nèi)容進(jìn)行處理和操作,有時(shí)候,我們可能需要知道某個(gè)特定文本在屏幕上的位置,以便進(jìn)行后續(xù)的操作,所以本文將介紹如何使用 Python 中的 Selenium 和 BeautifulSoup 庫(kù)來(lái)檢測(cè)網(wǎng)頁(yè)文本內(nèi)容在屏幕上的坐標(biāo),需要的朋友可以參考下2024-04-04