閉じる

もう+=しない!Python文字列は不変、速い連結はjoin一択

文字列連結を何気なく+=で書いていませんか。

Pythonの文字列は不変(immutable)なので、毎回新しいオブジェクトが作られます。

本稿では遅い+=連結を卒業し、速いstr.joinへ移行するために、仕組みと実用コード、判断基準を初心者向けに丁寧に解説します。

Pythonの文字列は不変(immutable)

不変とは何か

定義と背景

Pythonの文字列(str)は不変です。

これは一度作られた文字列の内容は変更できないという意味です。

文字を追加・削除・置換したい場合、元の文字列を元に新しい文字列が作られるという動作になります。

したがって+=などで連結すると、その都度新しいオブジェクトが生成されます。

ミュータブルとの対比

不変でない(ミュータブル)オブジェクトと比較すると、次のような違いがあります。

特性文字列(str, 不変)リスト(list, 変化可能)
要素の更新不可
追加/連結のコスト高い(新規作成が必要)低い(その場で伸長)
スレッド安全性比較的扱いやすい工夫が必要
代表例str, tuplelist, dict, set

サンプルで確認(idの変化)

次のサンプルでid(オブジェクトの識別子)が変わることを確認します。

Python
# 文字列は不変なので、連結のたびに新しいオブジェクトが作られます
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の優位が広がる傾向を確認できます。

Python
# 悪い例: ループで += する
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)

上記は一例ですが、連結回数が多いほど+=が指数的に不利になる様子がわかります。

メモリとパフォーマンスへの影響

コピーの総量が膨らむ

+=過去に作った部分も毎回コピー対象になります。

結果としてメモリの一時使用量が増え、ガベージコレクションも頻発し、全体のパフォーマンスを悪化させます。

巨大データでは顕著

大量の行を読み込みながら1つの文字列にまとめる処理などでは、パフォーマンス劣化やメモリ不足の原因になりやすいので注意が必要です。

速い文字列連結はstr.join一択

joinの基本(区切り文字とシーケンス)

APIの形と要件

"区切り文字".join(シーケンス)という形で使います。

引数のイテラブルはすべて文字列である必要があります。

数値などを含む場合はmap(str, ...)で文字列化します。

Python
# 基本: スペース区切りで結合
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)するのが最速で分かりやすいです。

Python
# 定番の組み立てパターン
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の得意分野です。

Python
# 改行区切りで行を結合
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つは同じ結果になりますが、後者の方が速くスケールします。

Python
# アンチパターン: ループで += 累積
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」に置き換えます。

Python
# 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も有効です。
Python
# 非常に大きいテキストを段階的に組み立てたいとき
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も不変です。

大量のバイナリ連結にはbytearrayio.BytesIOを検討します。

まとめ

Pythonの文字列は不変であり、連結のたびに新しいオブジェクトが作られるという性質が、ループ内+=の遅さの本質です。

実務ではpartsに集めてstr.joinで一括結合を基本にし、改行や区切り文字もjoinで表現します。

小さな1回限りの連結は+f-stringでも構いませんが、「連結が増えたらjoinへ」を合言葉にすれば、パフォーマンスとメモリの両面で堅実なコードになります。

Python 実践TIPS - コーディング効率化・Pythonic
この記事を書いた人
エーテリア編集部
エーテリア編集部

人気のPythonを初めて学ぶ方向けに、文法の基本から小さな自動化まで、実際に手を動かして理解できる記事を書いています。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!