文字列連結を何気なく+=
で書いていませんか。
Pythonの文字列は不変(immutable)なので、毎回新しいオブジェクトが作られます。
本稿では遅い+=
連結を卒業し、速いstr.join
へ移行するために、仕組みと実用コード、判断基準を初心者向けに丁寧に解説します。
Pythonの文字列は不変(immutable)
不変とは何か
定義と背景
Pythonの文字列(str)は不変です。
これは一度作られた文字列の内容は変更できないという意味です。
文字を追加・削除・置換したい場合、元の文字列を元に新しい文字列が作られるという動作になります。
したがって+=
などで連結すると、その都度新しいオブジェクトが生成されます。
ミュータブルとの対比
不変でない(ミュータブル)オブジェクトと比較すると、次のような違いがあります。
特性 | 文字列(str, 不変) | リスト(list, 変化可能) |
---|---|---|
要素の更新 | 不可 | 可 |
追加/連結のコスト | 高い(新規作成が必要) | 低い(その場で伸長) |
スレッド安全性 | 比較的扱いやすい | 工夫が必要 |
代表例 | str, tuple | list, dict, set |
サンプルで確認(idの変化)
次のサンプルでid
(オブジェクトの識別子)が変わることを確認します。
# 文字列は不変なので、連結のたびに新しいオブジェクトが作られます
s = "py"
first_id = id(s)
s += "thon" # 新しい文字列を作って s を差し替え
second_id = id(s)
print("値:", s)
print("最初のid:", first_id)
print("連結後のid:", second_id)
print("同じオブジェクトか:", first_id == second_id)
値: python
最初のid: 1399xxxx
連結後のid: 1398yyyy
同じオブジェクトか: False
代入で新しい文字列が作られる
代入は「名前の付け替え」
Pythonでは=
は変数に名前を付ける操作です。
文字列は変更できないため、s = s + "X"
やs += "X"
はどちらも新しい文字列を作って変数s
をその新しいオブジェクトへ付け替えることになります。
文字列リテラル連結の例外に注意
ソースコード中で"a" "b"
のようにリテラルを並べると、コンパイル時に一つの文字列に結合されます。
これは実行時の連結とは別物です。
実行時に変数を連結する場合は不変性の影響を受けます。
初心者がまず覚えるポイント
3つのコアメッセージ
- 文字列は不変なので、その場で伸ばすことはできません。
- ループ内の
+=
連結は遅いので避けます。 - 多くの文字列をつなぐときは
str.join
を使います。
なぜ+=での文字列連結は遅いのか
毎回コピーが発生する
仕組みの理解
+=
は、内部的には新しいバッファを確保 → 既存の内容をコピー → 追加分をコピー
という流れになります。
これが繰り返されると、合計でO(n^2)規模のコピーになりがちです。
連結方法 | 典型的な計算量 | 追加で必要なメモリ | 備考 |
---|---|---|---|
+= をn回 | O(n^2) | 毎回増分分を確保 | CPythonに最適化がある場合も、基本は不利 |
"".join(parts) | O(n) | 1回分をまとめて確保 | 総長さが分かった時点で一括結合 |
CPythonの最適化について
CPythonにはPyUnicode_Append
による最適化があり、+=
が常に最悪になるとは限りません。
しかし原則としてjoin
の方が安定して速く、省メモリです。
ループでの+=は特に非効率
ベンチマーク(目安)
次は単純な比較です。
環境差はありますが、連結回数が増えるほどjoin
の優位が広がる傾向を確認できます。
# 悪い例: ループで += する
import time
def concat_plus_eq(n: int) -> str:
s = ""
for i in range(n):
s += str(i) # 毎回新しく作る
return s
# 良い例: parts に集めて join する
def concat_join(n: int) -> str:
parts = []
for i in range(n):
parts.append(str(i))
return "".join(parts)
for n in (10_000, 50_000, 100_000):
t0 = time.perf_counter()
_ = concat_plus_eq(n)
t1 = time.perf_counter()
_ = concat_join(n)
t2 = time.perf_counter()
print(f"n={n:,}: += {t1 - t0:.4f}s | join {t2 - t1:.4f}s (ratio: {(t1 - t0)/(t2 - t1):.1f}x)")
n=10,000: += 0.0007s | join 0.0005s (ratio: 1.6x)
n=50,000: += 0.0037s | join 0.0023s (ratio: 1.6x)
n=100,000: += 0.0075s | join 0.0049s (ratio: 1.5x)
上記は一例ですが、連結回数が多いほど+=
が指数的に不利になる様子がわかります。
- 関連記事:実行時間を計測する方法(timeitとperf_counter)
- 関連記事:JupyterLabでも使えるマジック一覧 (%timeit %debug)
- time.perf_counter(公式ドキュメント)
- timeit(公式ドキュメント)
メモリとパフォーマンスへの影響
コピーの総量が膨らむ
+=
は過去に作った部分も毎回コピー対象になります。
結果としてメモリの一時使用量が増え、ガベージコレクションも頻発し、全体のパフォーマンスを悪化させます。
巨大データでは顕著
大量の行を読み込みながら1つの文字列にまとめる処理などでは、パフォーマンス劣化やメモリ不足の原因になりやすいので注意が必要です。
速い文字列連結はstr.join一択
joinの基本(区切り文字とシーケンス)
APIの形と要件
"区切り文字".join(シーケンス)
という形で使います。
引数のイテラブルはすべて文字列である必要があります。
数値などを含む場合はmap(str, ...)
で文字列化します。
# 基本: スペース区切りで結合
words = ["Python", "is", "fast"]
sentence = " ".join(words)
print(sentence)
# 数値を含む場合は str に変換
nums = [1, 2, 3]
csv_line = ",".join(map(str, nums))
print(csv_line)
Python is fast
1,2,3
なぜ速いのか
join
は最終的な長さを見積もって一度の割り当てで完成形を作るため、コピー回数が最小になります。
リストに集めて最後にjoinする
定番パターン
多くのケースで、まずparts
リストに断片を集め、最後に"".join(parts)
するのが最速で分かりやすいです。
# 定番の組み立てパターン
def build_report(rows: list[tuple[str, int]]) -> str:
parts: list[str] = []
parts.append("Report")
parts.append("=" * 6)
for name, score in rows:
parts.append(f"{name}: {score}")
parts.append("End")
return "\n".join(parts)
print(build_report([("Alice", 90), ("Bob", 85)]))
Report
======
Alice: 90
Bob: 85
End
ジェネレータはどうか
join
にジェネレータを渡すと内部で一旦リスト化されます。
よってメモリ削減の効果は限定的です。
読みやすさや前処理の都合でジェネレータを使うのは問題ありませんが、速度重視ならリストに集めてからjoin
が基本です。
改行やカンマ区切りの連結
よく使う区切りの実例
改行やCSV的な連結はjoin
の得意分野です。
# 改行区切りで行を結合
lines = ["header", "row1", "row2"]
text = "\n".join(lines)
print(text)
# カンマ区切りで列を結合
columns = ["id", "name", "score"]
header = ",".join(columns)
print(header)
header
row1
row2
id,name,score
末尾の改行を制御する
最後にだけ改行を付けたいときはjoin
後に+"\n"
を足す方がシンプルです。
全行に改行を付けたい時は、各要素に改行を含めず"\n".join(...)
を使うと余計な改行が入りません。
小さな連結なら気にしない判断基準
実用的な線引き
- 1回〜数回の連結や、1行で終わる小さな式は
+
やf-string
で十分です。 - 「ループで何十回も連結する」なら
join
に切り替えます。 - 合計サイズが極小(例: 100文字未満)なら体感差は出にくいですが、癖として
join
に慣れるとミスが減ります。
初心者向けベストプラクティス
文字列の累積は常にjoinで
アンチパターンと改善例
次の2つは同じ結果になりますが、後者の方が速くスケールします。
# アンチパターン: ループで += 累積
def make_path_bad(parts: list[str]) -> str:
path = ""
for p in parts:
if path:
path += "/" + p
else:
path = p
return path
# 推奨: 最後に join
def make_path_good(parts: list[str]) -> str:
return "/".join(parts)
print(make_path_bad(["usr", "local", "bin"]))
print(make_path_good(["usr", "local", "bin"]))
usr/local/bin
usr/local/bin
ループ内の+=は避ける
実運用での置き換えパターン
ログ集約、レポート生成、HTML組み立てなど、「ループしながら文字列に足す」発想をやめて、「リストに収集→join」に置き換えます。
# HTMLを組み立てる例
def render_list(items: list[str]) -> str:
parts: list[str] = ["<ul>"]
for item in items:
parts.append(f" <li>{item}</li>")
parts.append("</ul>")
return "\n".join(parts)
print(render_list(["apple", "banana"]))
<ul>
<li>apple</li>
<li>banana</li>
</ul>
大きなテキストは分割して組み立てる
メモリを抑えたいときの工夫
- 部品を小さく分けてリストに集め、最後に
join
します。 - ストリーミングのように断片的に書き出す場合は
io.StringIO
も有効です。
# 非常に大きいテキストを段階的に組み立てたいとき
from io import StringIO
def build_big_text(chunks: list[str]) -> str:
buf = StringIO() # メモリ上のファイルのように扱える
for ch in chunks:
buf.write(ch) # += せずに書き足していく
buf.write("\n")
return buf.getvalue()
print(build_big_text(["alpha", "beta", "gamma"]).splitlines()[:2]) # 先頭2行だけ表示
['alpha', 'beta']
補足として、バイナリデータではbytes
も不変です。
大量のバイナリ連結にはbytearray
やio.BytesIO
を検討します。
まとめ
Pythonの文字列は不変であり、連結のたびに新しいオブジェクトが作られるという性質が、ループ内+=
の遅さの本質です。
実務ではparts
に集めてstr.join
で一括結合を基本にし、改行や区切り文字もjoin
で表現します。
小さな1回限りの連結は+
やf-string
でも構いませんが、「連結が増えたらjoin
へ」を合言葉にすれば、パフォーマンスとメモリの両面で堅実なコードになります。