跳至主要内容

Python 字串組裝的效能比較

高見龍
五倍學院 負責人

在 Python 要組出一個 "Hello World" 字串有好幾種方法,有的看起來很簡單,但也可以寫的很囉嗦:

# 第一種
str1 = "Hello " + "World"

# 第二種
str2 = "Hello "
str2 += "World"

# 第三種
a = "Hello"
b = "World"
str3 = f"{a} {b}"

# 第四種
words = ["Hello", "World"]
str4 = " ".join(words)

大家猜猜看,在這四種寫法當中,哪一種寫法的效能可能會是最差的?為什麼加上「可能」,因為在不同的版本或硬體上也許會有不一樣的結果。

不同的串接方式

為了避免主觀猜測或自我感覺良好,我先從這幾種不同的寫法的編譯出來的 Bytecode 比較它們之間的差異。

使用 +

第一種寫法:

str1 = "Hello " + "World"`:

編譯之後的 Bytecode 如下:

  1           2 LOAD_CONST               0 ('Hello World')
4 STORE_NAME 0 (str1)

雖然原來的程式碼是 "Hello ""World" 這兩個字串相加,但 Python 判斷出這兩個字串都是常數,而且它們之間的 + 是單純的字串串接,所以在編譯階段就先計算並處理好,可以減低執行期的負擔,還能避免了額外的記憶體分配,這種最佳化手法又稱「常數折疊(Constant Folding)」。從 Bytecode 的結果可以看的出來這個寫法的效能,應該會跟直接指定 str1 = "Hello World" 的效能差不多,效能在這幾個裡面最好的。

使用 +=

第二種寫法:

str2 = "Hello "
str2 += "World"

編譯之後的 Bytecode 如下:

  1           2 LOAD_CONST               0 ('Hello ')
4 STORE_NAME 0 (str2)

2 6 LOAD_NAME 0 (str2)
8 LOAD_CONST 1 ('World')
10 BINARY_OP 13 (+=)
14 STORE_NAME 0 (str2)

跟第一個寫法比起來,除了沒有常數折疊的最佳化之外,指令還變多了,最後還多了一個 BINARY_OP 指令,這個指令實作的函數 PyUnicode_Concat() 雖然不算差,但每次執行都會產生一個新的字串物件,所以這效能不會比第一種寫法好。

使用 F 字串

第三種寫法:

a = "Hello"
b = "World"
str3 = f"{a} {b}"

編譯之後的 Bytecode 如下:

  1           2 LOAD_CONST               0 ('Hello')
4 STORE_NAME 0 (a)

2 6 LOAD_CONST 1 ('World')
8 STORE_NAME 1 (b)

3 10 LOAD_NAME 0 (a)
12 FORMAT_VALUE 0
14 LOAD_CONST 2 (' ')
16 LOAD_NAME 1 (b)
18 FORMAT_VALUE 0
20 BUILD_STRING 3
22 STORE_NAME 2 (str3)
24 RETURN_CONST 3 (None)

F 字串是 Python 的一種字串格式化方法,如果想要在字串裡面安插變數的話,可讀性應該會比一般的字串組裝好得多。可讀性雖然比較好,但這個寫法編譯出來的 Bytecode 指令更多了。這裡有兩個值得看的點,一個是執行了兩次的 FORMAT_VALUE 指令來做格式化之外,最後的 BUILD_STRING 是建立一個新的字串物件。字串的 FORMAT_VALUE 實作的原始碼是 PyObject_Format() 函數,它會呼叫字串物件身上的 __format__() 方法,這個方法也會建立一個新的字串物件,所以這個寫法的效能應該會比第二種寫法更差。

使用 join() 方法

來看看第四種寫法:

words = ["Hello", "World"]
str4 = " ".join(words)

編譯之後的 Bytecode 如下:

  1           2 LOAD_CONST               0 ('Hello')
4 LOAD_CONST 1 ('World')
6 BUILD_LIST 2
8 STORE_NAME 0 (words)

2 10 LOAD_CONST 2 (' ')
12 LOAD_ATTR 3 (NULL|self + join)
32 LOAD_NAME 0 (words)
34 CALL 1
42 STORE_NAME 2 (str4)
44 RETURN_CONST 3 (None)

這裡的重點在於呼叫 join() 方法的 CALL 指令,這個方法也會建立一個新的字串。雖然好像指令比較少,但不表示效能就是好的。這個 CALL 指令背後的實作原始碼是 PyUnicode_Join() 函數,這個函數裡有一個 _PyUnicode_JoinArray() 函數,它會先計算出所有要組裝的字串的總長度,然後一次建立所需要的記憶體空間來裝這個字串。看起來好像不錯,但我們這個範例只有兩個字串的相加,如果還得先計算總長度再建立字串物牛,根本佔不到便宜,所以效能可能會比第二種寫法再差一些,但會比第三種寫法好一點點。

效能測試

我們就實際來看看這四種寫法的效能吧!我的測試環境:

  • Apple M1 Pro(2021 年)
  • 記憶體 64 GB
  • macOS 14.4.1
  • Python 版本 3.12.7

這裡我用了內建的 timeit 模組,來看看這四種寫法的效能:

from timeit import timeit

# 前置作業
a = "Hello"
b = "World"
words = [a, b]

# 測試函數
def test_str1():
str1 = "Hello " + "World"

def test_str2():
str2 = "Hello "
str2 += "World"

def test_str3():
str3 = f"{a} {b}"

def test_str4():
str4 = " ".join(words)

n = 10_000_000 # 測試 1 千萬次

# 實測!
t1 = timeit("test_str1()", globals=globals(), number=n)
t2 = timeit("test_str2()", globals=globals(), number=n)
t3 = timeit("test_str3()", globals=globals(), number=n)
t4 = timeit("test_str4()", globals=globals(), number=n)

# 顯示結果
print(f"str1 (字串相加): {t1:.6f} 秒")
print(f"str2 (+= 累加): {t2:.6f} 秒")
print(f"str3 (f 字串): {t3:.6f} 秒")
print(f"str4 (join 方法): {t4:.6f} 秒")

因為我想把重點放在這幾種寫法的效能,為求公平,我把變數宣告以及陣列的建立放在測試函數的外部,然後讓每個函數執行 1 千萬次。測試結果如下:

str1 (字串相加): 0.247897 秒
str2 (+= 累加): 0.535874 秒
str3 (f 字串): 0.674152 秒
str4 (join 方法): 0.620409 秒

也許在不同的環境下,這些數字會有所不同,但在我的測試環境下,不意外的,效能最好的是第一種寫法(因為它幾乎等同於直接指定字串 str1 = "Hello World"),效能最差的是第三種 F 字串的寫法,而 .join() 方法險勝一點點。

量大的時候...

雖然在上面的評測中,除了第一種寫法之外,其它的寫法差異不大,但這是因為串接的字串數量不多,當字串數量變多時,這些差異就會變得明顯,特別是最後一個 .join() 方法。這回,我讓拼接的字串數量增加到 10 萬個,然後再來看看效能的差異,因為因為第一種寫法等於直接指定字串,所以這次的評測我就不算它一份了,我就只測試後面三種寫法:

from timeit import timeit

# 前置作業
words = [f"hey{i}" for i in range(100000)]

# 測試函數
def test_plus_equal():
result = ""
for word in words:
result += word
return result

def test_fstring():
result = ""
for word in words:
result = f"{result}{word}"
return result

def test_join():
return ''.join(words)

# 實測!
t2 = timeit("test_plus_equal()", globals=globals(), number=10)
t3 = timeit("test_fstring()", globals=globals(), number=10)
t4 = timeit("test_join()", globals=globals(), number=10)

# 顯示結果
print(f"str2 (大量 += 累加): {t2:.6f} 秒")
print(f"str3 (大量 f 字串): {t3:.6f} 秒")
print(f"str4 (大量 join 方法): {t4:.6f} 秒")

我讓每個測試函數執行 10 次,測試結果如下:

str2 (大量 += 累加): 0.041256 秒
str3 (大量 f 字串): 7.548484 秒
str4 (大量 join 方法): 0.004343 秒

.join() 方法大勝,甚至原本跟第二種 += 寫法差不多的效能,在大量字串拼接的情況下,效能整個被拉開了。

為什麼差這麼多?前面有提到 .join() 在開始進行拼接前,會先計算出所有字串的總長度,並一次性分配所需的記憶體空間,原始碼 _PyUnicode_JoinArray() 裡面有一行寫著 res = PyUnicode_New(sz, maxchar) 就是在做這件事,那個 sz 就是總長度。

所以,以後應該用 .join() 方法來拼接字串嗎?倒也未必,從結果來看,如果字串不多的時候,用 .join() 佔不到多少便宜,而且還得先把字串裝在串列裡,程式碼的可讀性並沒有比較好。除非是大量的字串串接,否則我還是會選擇用 += 或 F 字串的寫法,寫法簡單,可讀性高,效能也不會差太多。