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