1-03 リストとタプル

このページはプロモーション(広告)を含みます。

複数の値を順番に並べて扱う「リスト」と「タプル」を学びます。変更できる値に名前札が2枚付いたときに起きる落とし穴と、LLMの会話履歴がリストでできていることまでつなげます。

この章でできるようになること

ここまでの2章では、1つの変数に1つの値を結びつけてきました。しかし業務のデータは、ほとんどが「たくさんの値の並び」です。1週間の日別売上、ある顧客の注文の一覧、問い合わせの受付順、カートに入った商品。これらを変数 sales_mon、sales_tue、…と1つずつ作っていては、すぐに手に負えなくなります。

この章で学ぶリスト(list)は、複数の値を順番に並べて1つにまとめる入れ物です。Python で最もよく使うデータの形で、AIが書くコードのほぼすべてに登場します。あわせて、変更できない並びであるタプル(tuple)も学びます。

この章には、初学者が必ず一度はつまずく落とし穴があります。→ 1-01 値・型・変数 で学んだ「変数は名前札」という考え方が、ここで本領を発揮します。リストは中身を変更できるので、1つのリストに2枚の名前札が付いていると、片方から加えた変更がもう片方からも見えてしまうのです。仕組みを図で追いかけ、確実に見抜けるようにします。

最後に、LLM アプリとのつながりを見ます。LLM との会話は、「これまでの発言の並び」というリストとして API に送られます。会話履歴を正しく扱えることは、チャットボットを作るための最初の一歩です。

リストを作り、取り出す

リストは、角かっこ [] の中に値をカンマ区切りで並べて作ります。

アプリで実行できるコード

daily_sales = [128000, 95000, 143000, 87000, 156000]
products = ["ワイヤレスマウス", "USBハブ", "ノートPCスタンド"]
empty = []
print(daily_sales)
print(products)
print(len(daily_sales), len(empty))
print(type(daily_sales))

len() は、文字列では文字数でしたが、リストでは要素(中に入っている値)の数を返します。[] は要素が1つもない空のリストです。後から値を追加していくための出発点としてよく使います。

リストの各要素は、文字列と同じくインデックス(0から数える番号)で取り出せます。前の章(→ 1-02 文字列を扱う)で学んだことが、そのまま使えます。

実行前に出力を予想する

daily_sales = [128000, 95000, 143000, 87000, 156000]
print(daily_sales[0])
print(daily_sales[-1])
print(daily_sales[1:3])
print(daily_sales[:2])
print(daily_sales[-2:])

[0] は最初、[-1] は最後の要素です。スライス [1:3] は1番目と2番目の要素を取り出し、終了位置の3番目は含みません。スライスの結果は、元のリストの一部をコピーした新しいリストです。だから [1:3] の結果は、95000 ではなく [95000, 143000] のように角かっこ付きで表示されます。1つの要素を取り出すインデックスと、範囲を取り出すスライスでは、結果の型が違うことに注意してください。

daily_sales[0] は int の値そのもの、daily_sales[0:1] は要素が1つだけ入った list です。type() で確かめると、それぞれ int と list になります。

存在しない番号を指定すると、IndexError: list index out of range になります。5つの要素があるリストの最後の番号は 4 です。「長さが5なら番号は0〜4」という関係は、リストを扱う間ずっとついて回ります。

空のリストで [-1] や [0] を使う

症状:普段は動くのに、ある日突然 IndexError: list index out of range で処理が止まる。

原因:「最新の問い合わせ」を inquiries[-1]、「最初の検索結果」を results[0] で取り出していたが、その日は問い合わせが0件、または検索結果が0件だった。空のリストには、0番目も最後の要素も存在しない。テストデータには必ず何件か入っているので、開発中は気づきにくい。

対処:リストが空になりうるかを、データの出どころから考える。空になりうるなら、取り出す前に len(xs) > 0 を確かめる(→ 1-05 条件分岐)。LLM アプリでは、検索結果が0件のときや、モデルの返答の choices が想定と違うときに、この形のエラーがよく起きる。AIが書いたコードで [0] や [-1] を見たら、「ここは空になりえないか」と問うてください。

リストには何でも入る

リストの要素には、どんな型の値でも入れられます。型が混ざっていてもかまいませんし、リストの中にリストを入れることもできます。

アプリで実行できるコード

order = ["ORD-2026-000123", "田中", 3, True, None]
print(order[1], order[2])

ただし、型の違う値を1つのリストに混ぜるのは、多くの場合よい設計ではありません。order[2] が数量だと覚えておかなければならず、読み手にとって分かりにくいからです。リストは「同じ種類のものが並んでいる」ときに最も扱いやすくなります(日別の売上、商品名の一覧など)。種類の違う値をまとめる方法は、この章のタプルと、次の章(→ 1-04 辞書と集合)で学びます。

リストを変更する

文字列はイミュータブル(変更不可)でしたが、リストは中身を変更できます。この性質をミュータブル(mutable、変更可能)と呼びます。変更の方法はいくつかあります。

要素を置き換える

インデックスを指定して代入すると、その位置の要素が置き換わります。

アプリで実行できるコード

stock = [12, 0, 35, 8]
stock[1] = 20         # 入荷したので在庫を更新
stock[-1] = stock[-1] - 3   # 3個出荷
print(stock)

文字列で s[0] = "X" と書くと TypeError でしたが、リストではこれが正しく動きます。

追加する:append・extend・insert

リストの末尾に1つ追加するのが append() です。リストを変更する操作の中で、最もよく使います。

アプリで実行できるコード

queue = ["問い合わせ#101", "問い合わせ#102"]
queue.append("問い合わせ#103")
print(queue)

append() は元のリストそのものを変更します。文字列のメソッドが新しい文字列を返したのとは逆です。だから queue = queue.append(...) と書いてはいけません(その理由は「並べ替え」の節で分かります)。

複数の要素をまとめて追加したいときは extend() を使います。append() との違いを予想してください。

実行前に出力を予想する

a = ["返品", "交換"]
a.append(["返金", "解約"])
print(a, len(a))

b = ["返品", "交換"]
b.extend(["返金", "解約"])
print(b, len(b))

append() は、渡されたものを「1つの要素」として末尾に入れます。リストを渡せば、リストがまるごと1つの要素として入り、入れ子になります。要素は3つです。extend() は、渡されたリストの要素を1つずつ取り出して末尾に追加します。要素は4つです。「リストに別のリストの中身を足したい」のに append() を使うのは、よくある間違いです。

insert(位置, 値) は、指定した位置に要素を差し込みます。それ以降の要素は1つずつ後ろにずれます。

アプリで実行できるコード

queue = ["問い合わせ#101", "問い合わせ#102"]
queue.insert(0, "至急#099")   # 先頭に割り込み
print(queue)

取り除く:pop・remove・del

要素を取り除く方法は3つあります。

アプリで実行できるコード

queue = ["至急#099", "問い合わせ#101", "問い合わせ#102", "問い合わせ#103"]

first = queue.pop(0)      # 位置を指定して取り出す(取り出した値が返る)
print("対応開始:", first)

last = queue.pop()        # 位置を省略すると末尾を取り出す
print("末尾:", last)

queue.remove("問い合わせ#101")   # 値を指定して取り除く(最初の1つだけ)
print(queue)

del queue[0]              # 位置を指定して削除(値は返らない)
print(queue)

使い分けは次のとおりです。

操作 何で指定するか 取り除いた値は 見つからないとき
lst.pop(i) 位置(省略すると末尾) 返ってくる IndexError
lst.remove(x) 値(最初に一致した1つ) 返らない(None) ValueError
del lst[i] 位置(スライスも可) 返らない IndexError

pop() は「取り出して使う」場面に向いています。問い合わせの受付キュー(待ち行列)から次の1件を取り出して対応する、という処理です。remove() は値で探して消すので、同じ値が複数あると最初の1つしか消えない点に注意してください。

位置はずれる

insert()、pop()、remove()、del を使うと、それより後ろの要素の番号が1つずつずれます。予想してください。

実行前に出力を予想する

tasks = ["見積作成", "請求書発行", "在庫確認", "日報提出"]
done = tasks.pop(1)
print(done)
print(tasks[1])

pop(1) で "請求書発行" を取り出すと、その後ろの "在庫確認" が1番に繰り上がります。「さっき1番だった要素」と「今の1番の要素」は別物です。番号をメモしておいて後から使うコードや、リストを先頭から順に処理しながら要素を削除するコードは、このずれのせいで要素を飛ばしたり、別の要素を消したりします。リストを処理しながら削除したいときは、削除する要素を別のリストに集めてから後で消すか、残す要素だけで新しいリストを作るのが安全です(→ 1-09 内包表記とよく使う組み込み関数)。

大量のデータで先頭から取り出すなら

pop(0) や insert(0, ...) は、それ以降のすべての要素を1つずつずらすため、要素が数十万件あると遅くなります。先頭からの取り出しを頻繁に行うキューには、標準ライブラリの collections.deque を使います(→ 1-10 モジュールと標準ライブラリ)。数百件程度なら気にする必要はありません。

調べる・集計する

リストの中身を調べる道具は、文字列とよく似ています。

アプリで実行できるコード

daily_sales = [128000, 95000, 143000, 87000, 156000]
print(143000 in daily_sales)          # 含まれるか
print(daily_sales.index(87000))       # 最初に見つかった位置
print(daily_sales.count(95000))       # 出現回数
print(sum(daily_sales))               # 合計
print(min(daily_sales), max(daily_sales))
print(sum(daily_sales) // len(daily_sales))   # 平均(円未満切り捨て)

in は、要素として含まれるかを True / False で返します。sum()、min()、max() は、数のリストの合計・最小・最大を一度に計算する組み込み関数です。平均は「合計 ÷ 件数」で求めます。円の平均なので、前の章までの方針どおり // で整数にしています。

予想してください。文字列のときの in と、リストのときの in の違いが分かります。

実行前に出力を予想する

tags = ["返品", "至急", "VIP会員"]
print("VIP" in tags)
print("VIP" in "VIP会員")
print("VIP" in tags[2])

リストに対する in は、要素と完全に一致するかを調べます。"VIP" という要素はないので False です。文字列に対する in は、部分文字列として含まれるかを調べるので True です。3行目の tags[2] は文字列 "VIP会員" なので、部分一致で True になります。同じ in でも、左右の型によって意味が変わります。

リストどうしは == で比べられます。2つのリストの長さが同じで、同じ位置の要素がすべて等しいときだけ True になります。[1, 2, 3] == [3, 2, 1] は、要素の種類が同じでも順番が違うので False です。順番を無視して「同じものが入っているか」を比べたいときは、sorted(a) == sorted(b) のように並べ替えてから比べるか、次の章で学ぶ集合(→ 1-04 辞書と集合)を使います。演習の check で assert result == [15, 15, 12] のように書けるのも、この比較のおかげです。

空のリストに max() や min() を使うと ValueError になり、平均の計算では件数が0なので ZeroDivisionError になります。データが0件の日もありうる業務では、この境界を必ず考えます(扱い方は → 1-05 条件分岐)。

リストを1つずつ処理する(予告)

AIが書いたコードでは、リストの要素を1つずつ取り出して処理する for 文がほぼ必ず出てきます。詳しくは → 1-06 繰り返し で学びますが、読めるように形だけ見ておきます。

アプリで実行できるコード

daily_sales = [128000, 95000, 143000, 87000, 156000]
for amount in daily_sales:
    print(f"{amount:>9,}円")

for amount in daily_sales: は、「daily_sales の要素を先頭から1つずつ amount という名前で受け取り、字下げされた行を実行する」と読みます。要素が5つなら、字下げされた行が5回実行されます。sum() や max() も、内部ではこのように要素を1つずつ見ています。

並べ替え:sort と sorted

リストの並べ替えには、2つの方法があります。違いを予想してください。

実行前に出力を予想する

scores = [72, 95, 88, 61]
result = scores.sort()
print(result)
print(scores)

scores.sort() は、リストそのものを並べ替えます。そして何も返しません。正確には、「何もない」を表す None を返します。だから result は None で、並べ替えられたのは scores 自身です。

この「元のリストを変更するメソッドは None を返す」という規則は、Python 全体で一貫しています。append()、extend()、insert()、remove()、sort() はどれも、元のリストを変更して None を返します。queue = queue.append(...) と書いてはいけない理由はこれです。queue の名前札が None に付け替えられ、リストを見失ってしまいます。

元のリストを変えずに、並べ替えた新しいリストがほしいときは、組み込み関数 sorted() を使います。

アプリで実行できるコード

scores = [72, 95, 88, 61]
ranked = sorted(scores, reverse=True)
print(ranked)
print(scores)
print(ranked[:3])

sorted() は新しいリストを返し、元の scores はそのままです。reverse=True を付けると大きい順(降順)になります。sorted(...)[:3] の形は「上位3件」を取り出す定番の書き方で、売上ランキングや、検索結果の上位N件(→ 6-07 RAG(1):検索して答える)で頻繁に見かけます。

lst.sort() sorted(lst)
元のリスト 並べ替えられる 変わらない
戻り値 None 並べ替えた新しいリスト
使える対象 リストだけ 並びなら何でも(タプル、文字列など)

どちらを使うか迷ったら、sorted() を選ぶのが安全です。元のデータが変わらないので、後で「元の順番」が必要になっても困りません。

何の順番で並ぶのか

数は数の大小で、文字列は前の章で学んだとおり文字の番号(コードポイント)の順で並びます。

実行前に出力を予想する

print(sorted([100, 9, 10]))
print(sorted(["100", "9", "10"]))

数のリストは [9, 10, 100] と期待どおりに並びます。しかし文字列のリストは、先頭の文字から比べるので ['10', '100', '9'] になります。CSVから読んだ金額を文字列のまま並べると、ランキングが崩れる理由です。前の章までと同じく、「入口で型を確定させる」ことが大切です。

数と文字列が混ざったリストを並べようとすると、TypeError: '<' not supported between instances of 'str' and 'int' になります。数と文字列には大小関係が定義されていないからです。

「価格の順」「名前の長さの順」のように、並べる基準を自分で決める方法(key= 引数)は → 1-07 関数 と → 1-09 内包表記とよく使う組み込み関数 で学びます。

入れ子のリスト

リストの要素にリストを入れると、表のようなデータを表現できます。

アプリで実行できるコード

cart = [
    ["ワイヤレスマウス", 2980, 2],
    ["USBハブ", 1680, 1],
    ["ノートPCスタンド", 4980, 1],
]
print(len(cart))
print(cart[0])
print(cart[0][0], cart[0][1])
print(cart[-1][2])

cart[0] は最初の要素で、それ自体がリスト ["ワイヤレスマウス", 2980, 2] です。そこからさらに [1] で2番目の要素を取り出すと、価格の 2980 になります。cart[0][1] は、左から順に「cart の0番目」の「1番目」と読みます。

 cart
  ├─ [0] ──> ["ワイヤレスマウス", 2980, 2]
  │              [0]           [1]   [2]
  ├─ [1] ──> ["USBハブ", 1680, 1]
  └─ [2] ──> ["ノートPCスタンド", 4980, 1]

入れ子のリストは、表計算ソフトの「行と列」と同じ形です。外側のリストの番号が行、内側のリストの番号が列にあたります。

ただし、cart[0][1] が「価格」だと覚えておくのは大変です。コードを読む人は、[1] の意味を毎回推測しなければなりません。このような「意味の違う値の組」には、次の章で学ぶ辞書({"name": ..., "price": ...})を使うと、item["price"] のように名前で取り出せて読みやすくなります(→ 1-04 辞書と集合)。

タプル:変更できない組

タプル(tuple)は、リストとよく似た「値の並び」ですが、作った後で変更できません。丸かっこ () で作ります。

アプリで実行できるコード

product = ("P-001", "ワイヤレスマウス", 2980)
print(product[1], product[-1])
print(len(product), type(product))

取り出し方(インデックス、スライス、len、in)はリストと同じです。違いは、変更できないことです。

product[2] = 2480
TypeError: 'tuple' object does not support item assignment

append() や sort() のような、中身を変更するメソッドもありません。タプルは文字列と同じく、イミュータブルです。

要素が1つのタプル

タプルには、1つだけ有名な落とし穴があります。予想してください。

実行前に出力を予想する

a = (2980)
b = (2980,)
print(type(a))
print(type(b))

(2980) は、計算の順番を示す丸かっこで囲まれた、ただの整数です。(1 + 2) * 3 の丸かっこと同じ意味になります。要素が1つのタプルを作るには、(2980,) のように末尾にカンマを付けます。実は、タプルを作っているのは丸かっこではなくカンマなのです。t = 1, 2, 3 と丸かっこなしで書いても、タプルになります。

アンパック:まとめて受け取る

タプルやリストの要素を、複数の変数に一度に代入できます。これをアンパック(unpacking)と呼びます。

アプリで実行できるコード

product = ("P-001", "ワイヤレスマウス", 2980)
code, name, price = product
print(code)
print(name, price)

boxes, leftover = divmod(50, 12)   # divmod は (商, 余り) のタプルを返す
print(boxes, leftover)

code, name, price = product は、右側のタプルの要素を、左側の名前に順番に結びつけます。左右の数が合わないと ValueError: too many values to unpack などのエラーになります。

divmod(50, 12) は、→ 1-01 値・型・変数 で学んだ 50 // 12 と 50 % 12 を一度に計算し、(4, 2) というタプルで返す組み込み関数です。このように、複数の値を返す関数は、タプルで返してアンパックで受け取る形が一般的です。前の章の演習で書いた user, domain = email.split("@") も、リストのアンパックでした。

アンパックを使うと、2つの変数の値を入れ替えるのも1行で書けます。

実行前に出力を予想する

first = "田中"
second = "佐藤"
first, second = second, first
print(first, second)

右側の second, first が先に計算されて、("佐藤", "田中") というタプルが作られます。その後で、左側の first と second にそれぞれ結びつけられます。右側を先に計算してから名前を付ける、という → 1-01 値・型・変数 の規則どおりの動きです。

* を付けた変数を使うと、「残り全部」をリストで受け取れます。

header, *rows = ["日付,売上", "10/1,128000", "10/2,95000"]
print(header)   # 日付,売上
print(rows)     # ['10/1,128000', '10/2,95000']

header, *rows = ... は「先頭を header に、残りを全部 rows に」という意味です。CSVの1行目(見出し)とそれ以外を分けるときなどに見かけます。

リストとタプルの使い分け

どちらも「値の並び」ですが、使いどころが違います。

リスト タプル
変更 できる できない
典型的な中身 同じ種類の値が、いくつでも並ぶ 意味の違う値が、決まった数だけ組になる
例 日別の売上、問い合わせの一覧、会話履歴 (商品コード, 商品名, 価格)、(緯度, 経度)、(商, 余り)
件数 増えたり減ったりする 決まっている

目安として、「後から追加・削除する」ならリスト、「1件分のレコードとして固定の組を表す」ならタプルです。タプルは変更できないので、関数の結果を受け渡すときに、途中でうっかり書き換えられる心配がありません。また、変更できないことを利用して、次の章で学ぶ辞書のキーとして使うこともできます(→ 1-04 辞書と集合)。

ミュータブルの落とし穴:1つのリストに2枚の名前札

この章で最も重要な節です。→ 1-01 値・型・変数 で、次のような動きを見ました。

price_a = 1980
price_b = price_a
price_a = 2200
print(price_b)   # 1980 のまま

price_a = 2200 は、price_a の札を新しい値に「付け替える」操作なので、price_b には影響しませんでした。では、リストではどうなるでしょうか。

実行前に出力を予想する

store_a = [1980, 2980, 4980]
store_b = store_a
store_b[0] = 1780
print(store_a)
print(store_b)

店舗Bの価格表を店舗Aからコピーしたつもりで、店舗Bの価格を1つ値下げしました。ところが、店舗Aの価格表まで 1780 に変わっています。

図で追いかける

なぜこうなるのかを、名前札の図で追いかけます。

① store_a = [1980, 2980, 4980]

   [store_a] ──> [1980, 2980, 4980]

② store_b = store_a    (同じリストに、もう1枚の札を付ける。コピーはしない)

   [store_a] ──┐
               ├──> [1980, 2980, 4980]
   [store_b] ──┘

③ store_b[0] = 1780    (札が指しているリストの「中身」を書き換える)

   [store_a] ──┐
               ├──> [1780, 2980, 4980]
   [store_b] ──┘

store_b = store_a は、リストをコピーしません。同じ1つのリストに、2枚目の名前札を付けるだけです。そして store_b[0] = 1780 は、札の付け替えではなく、札が指しているリストの中身の書き換えです。リストは1つしかないので、どちらの札から見ても、書き換わった結果が見えます。

整数の例との違いを整理します。

整数や文字列はイミュータブルなので、そもそも「中身の書き換え」ができません。だから→ 1-01 値・型・変数 と→ 1-02 文字列を扱う では、この問題が起きませんでした。中身を書き換えられるリストだから起きる問題です。同じ値に複数の名前が付いている状態を、エイリアス(alias、別名)と呼びます。

2つの名前が同じものを指しているかは、→ 1-01 値・型・変数 の発展で紹介した is で確かめられます。

アプリで実行できるコード

store_a = [1980, 2980, 4980]
store_b = store_a
store_c = [1980, 2980, 4980]
print(store_a is store_b, store_a == store_b)
print(store_a is store_c, store_a == store_c)

store_c は、同じ中身で別に作ったリストです。中身は等しい(== が True)けれど、別のもの(is が False)です。store_c を変更しても、store_a には影響しません。

コピーを作る

別々のリストとして扱いたいときは、明示的にコピーを作ります。方法は3つあり、どれも同じ結果になります。

アプリで実行できるコード

store_a = [1980, 2980, 4980]
store_b = store_a.copy()     # copy メソッド
store_c = list(store_a)      # list() で新しいリストを作る
store_d = store_a[:]         # 全体のスライス
store_b[0] = 1780
print(store_a)
print(store_b)
print(store_a is store_b)

AIが書いたコードで xs[:] という見慣れない書き方を見たら、「全体のスライス、つまりコピーを作っている」と読みます。スライスは新しいリストを作るからです。

+= の罠

似ているのに、動きが違う書き方があります。予想してください。

実行前に出力を予想する

base = ["返品", "交換"]
alias = base
alias = alias + ["返金"]
print(base)

base = ["返品", "交換"]
alias = base
alias += ["返金"]
print(base)

alias = alias + ["返金"] は、右側の + で新しいリストを作り、alias の札をそちらへ付け替えます。base は元のままです。

alias += ["返金"] は、リストの場合「元のリストを extend() する」という意味になります。中身の書き換えなので、同じリストを指している base からも変更が見えます。→ 1-01 値・型・変数 で += は x = x + ... と同じだと説明しましたが、それは整数や文字列のようなイミュータブルな値の場合です。ミュータブルなリストでは、+= はその場で書き換えます。正直に言って、分かりにくい仕様です。リストに += を見たら「元のリストを書き換えている」と読み、他にそのリストを指している名前がないかを確認してください。

浅いコピーと、入れ子のリスト

コピーにも落とし穴があります。copy() や [:] は、外側のリストだけをコピーする浅いコピー(shallow copy)です。

実行前に出力を予想する

template = [["件名", ""], ["本文", ""]]
draft = template.copy()
draft[0][1] = "返品について"
print(template)

draft は template のコピーなので、安心して書き換えたつもりです。ところが template まで変わっています。図で確かめます。

 浅いコピー:外側のリストは別々だが、中の要素(内側のリスト)は共有している

   [template] ──> [ ● , ● ]
                    │    └──────────> ["本文", ""]
                    └──────────────> ["件名", "返品について"]
   [draft]    ──> [ ● , ● ]             ↑
                    │    └──── 同じものを指している
                    └──────────────────┘

copy() が作るのは、「内側のリストへの名前札」を並べ直した新しい外側のリストです。内側のリストそのものは複製されず、template と draft で共有されています。draft[0][1] = ... は、共有している内側のリストの中身を書き換えるので、template からも見えます。

入れ子の中身まで丸ごと複製したいときは、標準ライブラリ copy の deepcopy()(深いコピー)を使います。

アプリで実行できるコード

import copy

template = [["件名", ""], ["本文", ""]]
draft = copy.deepcopy(template)
draft[0][1] = "返品について"
print(template)
print(draft)

[[0] * 3] * 3 の罠

最後に、表の初期化でよく起きる罠です。* でリストを繰り返すと、同じ要素が並んだリストを作れます。

実行前に出力を予想する

shift = [["休"] * 3] * 2     # 2人 × 3日のシフト表のつもり
shift[0][0] = "出"
print(shift)

1人目の1日目だけを「出」にしたつもりが、2人目の1日目まで「出」になっています。[["休"] * 3] * 2 の外側の * 2 は、内側のリスト ["休", "休", "休"] を2回複製するのではなく、同じ1つのリストへの名前札を2つ並べるからです。

   [shift] ──> [ ● , ● ]
                 │   │
                 └─┬─┘
                   └──> ["出", "休", "休"]   (1つしかない)

内側の ["休"] * 3 は問題ありません。中身の "休" は文字列でイミュータブルなので、共有していても書き換えられないからです。問題は、ミュータブルなリストを * で繰り返したときです。正しくは、内側のリストを人数分、別々に作ります。

アプリで実行できるコード

shift = [["休"] * 3, ["休"] * 3]
shift[0][0] = "出"
print(shift)

人数が多いときは、→ 1-09 内包表記とよく使う組み込み関数 で学ぶ書き方 [["休"] * 3 for _ in range(人数)] を使います。AIが書いたコードでこの形を見たら、「内側のリストを毎回新しく作っている、正しい初期化」と読めます。

リストを変更したつもりがないのに、値が変わっている

症状:あるリストを加工しただけなのに、別の場所にある「元のデータ」まで変わっている。どこで変わったのか、追いかけても見つからない。

原因:同じリストに複数の名前札が付いている(エイリアス)。b = a、+=、浅いコピー、[[...]] * n のどれかで、気づかないうちにリストを共有している。関数にリストを渡したときにも同じことが起きる(→ 1-07 関数)。

対処:

  1. 変わってしまったリストと、変更した側のリストで print(a is b) を確かめる。True なら同じリスト。
  2. 独立させたい場所で .copy() を使う。入れ子なら copy.deepcopy() を使う。
  3. そもそも元のリストを変更しない書き方(sorted()、a + [x]、スライス)を選ぶ。
「元のデータを書き換えない」を既定にする

実務のコードでは、受け取ったリストをその場で書き換える処理(sort()、append()、+=)は、意図がはっきりしている場所に限定します。データを加工するときは、新しいリストを作って返す(sorted()、スライス、a + [x])のを既定にすると、エイリアスによるバグをほぼ防げます。

その場で書き換えるほうが速くメモリも少なく済む、という利点はあります。ただ、業務アプリで扱う数千件程度のデータなら、ほとんどの場合その差は問題になりません。「速さ」より「どこで変わったか追える」ことを優先する、というのが現場での一般的な判断です。この考え方は、→ 2-09 設計の基本 で「副作用を境界に集める」として再び出てきます。

なぜ Python は代入のたびにコピーしないのか(分からなくても先に進んでかまいません)

b = a で自動的にコピーが作られれば、エイリアスの問題は起きません。それでも Python がコピーしない設計になっているのには理由があります。

1つは性能です。リストには数百万件の要素が入ることもあります。代入や関数への受け渡しのたびに全要素をコピーすると、時間もメモリも膨大にかかります。名前札を付け替えるだけなら、要素数に関係なく一瞬で済みます。

もう1つは一貫性です。Python では、代入も関数への引数の受け渡しも、すべて「同じオブジェクトに名前札を付ける」という1つの規則で動きます。規則が1つなので、仕組みさえ理解すれば、どんな場面でも動きを予想できます。JavaScript や Java、Ruby など、多くの言語が同じ方式を採っています。「必要なときだけ、明示的にコピーする」のが、これらの言語に共通する作法です。

会話履歴はリストである

ここまでの内容を、LLM アプリにつなげます。チャットボットとの会話は、API には発言のリストとして送ります。このリストを messages と呼ぶのが慣習です。

アプリで実行できるコード

messages = [
    {"role": "system", "content": "あなたはECサイトのサポート担当です。丁寧に短く答えてください。"},
    {"role": "user", "content": "注文した商品はいつ届きますか"},
    {"role": "assistant", "content": "通常3営業日以内にお届けします。"},
    {"role": "user", "content": "返品はできますか"},
]
print(len(messages))
print(messages[0])
print(messages[-1]["content"])

リストの各要素は {"role": ..., "content": ...} という形をしています。これは次の章で学ぶ辞書(dict)で、「役割(role)」と「発言内容(content)」の組を表しています。今は、messages[-1]["content"] を「最後の発言の、内容」と読めれば十分です(→ 1-04 辞書と集合)。

role には主に3種類あります。system はモデルへの全体的な指示、user は利用者の発言、assistant はモデルの過去の返答です。この並び全体を、毎回 API に送ります。

LLM は会話を覚えていない

なぜ毎回、過去の発言まで全部送るのでしょうか。それは、LLM の API がステートレス(stateless、状態を持たない)だからです。API は1回の呼び出しごとに独立していて、前回の会話を覚えていません。チャット画面で会話が続いているように見えるのは、アプリ側が会話履歴のリストを保持し、毎回それを丸ごと送っているからです(→ 6-01 LLM API の基本)。

模擬LLM(llmsim)で、会話を2往復させてみます。模擬LLMは決まったルールで応答する練習用のモデルで、本物の LLM ではありません。ただし、messages の渡し方と、返答の受け取り方は本物の API と同じです。

アプリで実行できるコード

from llmsim import OpenAI, Rules

rules = Rules()
rules.when("返品", "購入から30日以内であれば返品を受け付けています。")
rules.when("送料", "返品の送料は、不良品の場合は当社が負担します。")
rules.otherwise("担当者からご連絡します。")
client = OpenAI(responder=rules)   # 模擬モデル

# 1往復目:上で作った messages は、最後が利用者の質問になっているので、そのまま送る
resp = client.chat.completions.create(model="sim-small", messages=messages)
reply = resp.choices[0].message.content
messages.append({"role": "assistant", "content": reply})

# 2往復目
messages.append({"role": "user", "content": "送料はかかりますか"})
resp = client.chat.completions.create(model="sim-small", messages=messages)
messages.append({"role": "assistant", "content": resp.choices[0].message.content})

print(len(messages), "件の発言")
print(messages[-3]["content"])
print(messages[-1]["content"])
print("2回目に送った件数:", len(client.history[-1]["messages"]))
print("2回目の入力トークン:", resp.usage.prompt_tokens)

会話の流れは、リストの操作そのものです。

① 利用者の発言を append() で末尾に追加する。 ② リスト全体を API に送る。 ③ モデルの返答を append() で末尾に追加する。

client.history は、模擬クライアントが「実際に送られた内容」を記録したものです。2回目の呼び出しでは、それまでの発言がすべて送られていることが分かります。会話が長くなるほど、送る発言が増え、入力のトークン数(料金と処理時間)も増えていきます。

履歴が伸びると、料金も伸びる

上の例では、1回目の呼び出しで4件、2回目の呼び出しで6件の発言を送っていました。会話が続くほど、送る量が増えていくことが分かります。1往復ごとに利用者の発言とモデルの返答の2件が増えるので、10往復目には20件以上の発言を毎回送ることになります。LLM の料金は、基本的に送った量(入力トークン)と返ってきた量(出力トークン)で決まります。つまり、会話の後半ほど1回あたりの料金が高くなります。

これは、チャットボットの運用コストを見積もるときに見落とされがちな点です。「1回の質問あたり◯円」という見積もりは、会話の1往復目だけで計算すると大きく外れます。履歴をどこまで送るかは、品質(文脈を覚えているか)と料金・速度のトレードオフで決めます(→ 8-03 コストとレイテンシの最適化)。

長くなった履歴を切り詰める

会話が長くなると、料金が増えるだけでなく、モデルが一度に受け取れる量(コンテキストの上限)を超えてしまいます(→ 5-02 トークンとコンテキスト)。そこで、古い発言を落とす処理を入れます。最も単純な方法は、「最初の system の指示は残し、残りは最新の数件だけにする」というものです。スライスで書けます。

アプリで実行できるコード

KEEP = 4
trimmed = messages[:1] + messages[-KEEP:]
print(len(messages), "->", len(trimmed))
print([m["role"] for m in trimmed])

messages[:1] は先頭の system 1件だけのリスト、messages[-KEEP:] は最後の4件のリストです。+ でつなげると、新しいリストができます。元の messages は変更されないので、全履歴は別に残しておけます。最後の行の [m["role"] for m in trimmed] は、各発言の role だけを並べたリストを作る書き方です(→ 1-09 内包表記とよく使う組み込み関数)。ここでは確認のために使っています。

古い発言を落とすと、モデルは会話の前半を「忘れる」ことになります。要約を残すなど、より賢い方法は → 6-05 会話の状態とメモリ で学びます。

例の一覧から、プロンプトを組み立てる

リストとタプルは、プロンプトに「お手本」を入れるときにも使います。LLM に分類をさせるとき、正解の例をいくつか見せると精度が上がりやすくなります。この手法をfew-shot(フューショット)と呼びます(→ 6-02 プロンプトエンジニアリング)。お手本は「(問い合わせ文, 分類)」という意味の決まった組なので、タプルのリストで持つのが自然です。

アプリで実行できるコード

examples = [
    ("届いた商品が壊れていました", "返品"),
    ("まだ商品が届きません", "配送"),
    ("請求額が注文時と違います", "請求"),
]
few_shot = [{"role": "system", "content": "問い合わせを分類し、分類名だけを答えてください。"}]
for text, label in examples:
    few_shot.append({"role": "user", "content": text})
    few_shot.append({"role": "assistant", "content": label})
few_shot.append({"role": "user", "content": "サイズが合わないので交換したい"})
print(len(few_shot), "件")
print([m["role"] for m in few_shot])

for text, label in examples: は、リストの要素(タプル)を1つずつ取り出し、アンパックで text と label に分けて受け取っています。お手本1組から、利用者の発言とモデルの返答の2件を作って few_shot に追加しています。お手本はタプルなので、処理の途中で誤って書き換えられる心配がありません。お手本の一覧は、プロンプトを改善するたびに追加・削除するので、外側はリストにしています。「外側は増減するのでリスト、1件分は固定の組なのでタプル」という使い分けの典型です。

会話履歴を共有してしまう事故

この章の「2枚の名前札」の落とし穴は、LLM アプリで深刻な事故につながります。たとえば、全利用者に共通の初期設定として BASE = [{"role": "system", ...}] を作り、利用者ごとに history = BASE として会話を始めたとします。history.append(...) は BASE そのものを書き換えるので、ある利用者の発言が、全利用者の会話履歴に混ざります。他人の問い合わせ内容や個人情報が、別の利用者への返答に影響しうるという、情報漏えいにつながるバグです。

利用者ごとの履歴は、必ず history = BASE.copy() や history = BASE + [...] のように、新しいリストとして作ってください。

AIが書いたコードで、この書き方を見たらこう読む

AIが書く Python コードには、リストとタプルの決まった書き方が繰り返し出てきます。

results = []
results.append(item)
top3 = sorted(scores, reverse=True)[:3]
latest = messages[-6:]
history = base_messages.copy()
name, price = row
first, *rest = lines
for i, (code, qty) in enumerate(pairs):

AIは、エイリアスの問題を含むコードを書くことがあります。特に、default = [] のような共有の初期値をそのまま使い回すコードや、関数の引数のデフォルト値にリストを使うコード(→ 1-07 関数 で学ぶ「ミュータブルなデフォルト引数の罠」)です。リストが = で別の名前に渡されている箇所を見たら、「コピーなのか、同じものなのか」を必ず確認してください。

リストの挙動をAIに確かめさせる

エイリアスのように「直感と違う挙動」は、AIに図で説明させると理解が深まります。「b = a の後で b.append(1) すると a も変わる理由を、名前札と値の図で説明して」と頼んでみてください。説明を読んだら、+= の場合や浅いコピーの場合について、自分で予想を立ててからAIに確かめます。

AIにリストを扱うコードを書かせるときは、「元のリストを変更しないで」と明示すると、sorted() やコピーを使ったコードが返ってきやすくなります。逆に、指示しないと sort() で元データを並べ替えてしまうコードを書くことがあります。受け取ったコードは、「この処理の後で、元のリストはどうなっているか」を print で確かめてください。

AIへの指示の例:エイリアスのバグを探してもらう
次の Python コードで、リストのエイリアス(同じリストを複数の変数が指している状態)によって、意図しない変更が起きている箇所がないか確認してください。

確認してほしいこと:
- `=` で別の変数に渡しているリストのうち、後で中身が変更されているもの
- `+=`、`append`、`sort` など、元のリストを書き換える操作
- 浅いコピー(copy や [:])では足りない、入れ子のリスト
- `[[...]] * n` の形の初期化

問題があれば、その行と、なぜ問題かを初心者向けに説明し、修正案を示してください。

{ここにコードを貼る}

演習

演習:今週の売上をまとめる

月曜から木曜までの売上がリスト daily_sales に入っています。

  1. 金曜の売上 156000 を末尾に追加する
  2. total に合計、best に最大値、days に日数を代入する
  3. average に1日あたりの平均(円未満切り捨て、int)を代入する
解答例
daily_sales = [128000, 95000, 143000, 87000]

daily_sales.append(156000)

total = sum(daily_sales)
best = max(daily_sales)
days = len(daily_sales)
average = total // days

print(daily_sales)
print(total, best, days, average)

演習:売上ランキングの上位3件

営業担当者ごとの今月の成約件数が deals に入っています。

解答例
deals = [12, 7, 15, 3, 9, 15]

top3 = sorted(deals, reverse=True)[:3]
worst = min(deals)

print(top3, worst)
print(deals)

演習:商品レコードをタプルで扱う

商品の情報が (商品コード, 商品名, 単価) のタプルで、リスト catalog に入っています。

  1. catalog の2番目の商品(インデックス1)を、アンパックで code、name、price に受け取る
  2. 新しい商品 ("P-004", "Webカメラ", 6980) を catalog の末尾に追加する
  3. last_price:catalog の最後の商品の単価
  4. boxes、leftover:在庫 stock = 50 を1箱12個で詰めたときの箱数と余りを、divmod() とアンパックで求める
解答例
catalog = [
    ("P-001", "ワイヤレスマウス", 2980),
    ("P-002", "USBハブ", 1680),
    ("P-003", "ノートPCスタンド", 4980),
]
stock = 50

code, name, price = catalog[1]

catalog.append(("P-004", "Webカメラ", 6980))

last_price = catalog[-1][2]
boxes, leftover = divmod(stock, 12)

print(code, name, price)
print(len(catalog), last_price)
print(boxes, leftover)

演習:店舗の価格表のバグを直す

本部の標準価格表 standard をもとに、店舗Aと店舗Bの価格表を作ります。店舗Aは1番目の商品だけ値下げ、店舗Bは新商品を1つ追加します。ところが次のコードでは、本部の標準価格表まで変わってしまいます。

標準価格表 standard が変わらないように直してください。期待する結果は次のとおりです。

さらに、入れ子のリストである template(帳票のひな形)から report を作って書き込んでも、template が変わらないようにしてください。

解答例
import copy

standard = [1980, 2980, 4980]
store_a = standard.copy()
store_b = standard.copy()

store_a[0] = 1780
store_b += [6980]

template = [["店舗名", ""], ["売上", 0]]
report = copy.deepcopy(template)
report[0][1] = "渋谷店"
report[1][1] = 1250000

print(standard, store_a, store_b)
print(template)
print(report)

演習:会話履歴を組み立てて切り詰める

サポート用チャットボットの、会話履歴を管理する部分を作ります。starter には、模擬LLMの準備と、これまでの会話 past_turns が用意されています。

  1. messages:SYSTEM の発言、past_turns の全発言、新しい質問 {"role": "user", "content": new_question} を、この順に並べた新しいリスト。past_turns 自体は変更しないこと(他の画面でも使っているため)
  2. messages を模擬LLMに送り、返答の文字列を reply に受け取る(このコードは starter に書いてある)
  3. 返答を {"role": "assistant", "content": reply} として messages の末尾に追加する
  4. trimmed:次回送信用に短くした履歴。先頭の system 1件と、messages の最後の4件をつなげた新しいリスト

発言1件は {"role": "user", "content": "..."} の形で書きます(中かっこの形は次の章で学ぶ辞書です。今は形をそのまま書けば大丈夫です)。

解答例
from llmsim import OpenAI, Rules

rules = Rules()
rules.when("返品", "購入から30日以内であれば返品を受け付けています。")
rules.otherwise("担当者からご連絡します。")
client = OpenAI(responder=rules)   # 模擬モデル

SYSTEM = {"role": "system", "content": "あなたはECサイトのサポート担当です。"}
past_turns = [
    {"role": "user", "content": "注文した商品はいつ届きますか"},
    {"role": "assistant", "content": "通常3営業日以内にお届けします。"},
    {"role": "user", "content": "送料はかかりますか"},
    {"role": "assistant", "content": "5,000円以上のご注文で無料です。"},
]
new_question = "返品はできますか"

# ① 送信するリストを作る(past_turns は変更しない)
messages = [SYSTEM] + past_turns + [{"role": "user", "content": new_question}]

# ② 送信して返答を受け取る
resp = client.chat.completions.create(model="sim-small", messages=messages)
reply = resp.choices[0].message.content

# ③ 返答を履歴に追加する
messages.append({"role": "assistant", "content": reply})

# ④ 次回用に切り詰める(先頭の system + 最後の4件)
trimmed = messages[:1] + messages[-4:]

print(len(messages), len(trimmed))
print(trimmed[-1])

理解度チェック

次のコードの出力は?

a = [1, 2, 3]
b = a
b.append(4)
print(a)
  1. ① [1, 2, 3]
  2. ② [1, 2, 3, 4]
  3. ③ [4]
  4. ④ エラーになる
解答と解説

正解:②

b = a はリストをコピーせず、同じリストに2枚目の名前札を付けるだけです。b.append(4) はそのリストの中身を書き換えるので、a から見ても4が追加されています。

次のコードの出力は?

x = [3, 1, 2]
y = x.sort()
print(y)
  1. ① [1, 2, 3]
  2. ② [3, 1, 2]
  3. ③ None
  4. ④ エラーになる
解答と解説

正解:③

sort() は元のリストをその場で並べ替え、None を返します。並べ替えた新しいリストがほしいときは sorted(x) を使います。

a = ["返品"] の後、a.append(["返金", "交換"]) を実行したときの len(a) は?

  1. ① 3
  2. ② 2
  3. ③ 1
  4. ④ エラーになる
解答と解説

正解:②

append() は渡されたものを1つの要素として追加するので、リスト全体が1つの要素になり、["返品", ["返金", "交換"]] で要素は2つです。要素を1つずつ追加したいなら extend() を使います。

(2980) の型は?

  1. ① tuple
  2. ② list
  3. ③ int
  4. ④ エラーになる
解答と解説

正解:③

丸かっこだけではタプルになりません。(2980) は計算の順番を示す丸かっこで囲まれた整数です。要素1つのタプルは (2980,) とカンマを付けて書きます。タプルを作っているのはカンマです。

次のコードの出力は?

grid = [[0] * 2] * 2
grid[0][0] = 9
print(grid)
  1. ① [[9, 0], [0, 0]]
  2. ② [[9, 0], [9, 0]]
  3. ③ [[9, 9], [0, 0]]
  4. ④ エラーになる
解答と解説

正解:②

外側の * 2 は、内側のリストを複製せず、同じ1つのリストへの参照を2つ並べます。そのため grid[0] と grid[1] は同じリストで、片方の書き換えが両方に見えます。内側のリストを別々に作る必要があります。

入れ子のリスト t = [["件名", ""]] から d = t.copy() を作り、d[0][1] = "返品" としたとき、t はどうなる?

  1. ① 変わらない(コピーしたので)
  2. ② [["件名", "返品"]] に変わる
  3. ③ d だけが変わり、t は空になる
  4. ④ エラーになる
解答と解説

正解:②

copy() は外側のリストだけをコピーする浅いコピーで、内側のリストは共有されています。内側まで複製したいときは copy.deepcopy(t) を使います。

LLM の API に会話履歴の messages を毎回すべて送る理由として正しいのは?

  1. ① 送らないとエラーになる仕様だから
  2. ② API は呼び出しごとに独立しており、過去の会話を覚えていないから
  3. ③ 毎回送ると料金が安くなるから
  4. ④ モデルの学習データに追加するため
解答と解説

正解:②

LLM の API はステートレスで、前回の呼び出しの内容を保持していません。会話が続いているように見えるのは、アプリ側が履歴のリストを保持して毎回送っているからです。そのため会話が長くなるほど入力トークンと料金が増え、切り詰めなどの管理が必要になります。

まとめ

参考資料

いずれも任意です。この章の内容はアプリ内で完結しています。

Groundwork AIエンジニア養成課程:AIエンジニアを目指す人のための全85章の課程。Pythonの基礎、Git・テスト、Claude CodeやCodexと一緒に開発する方法、LLMの仕組みとアプリ開発までを、ブラウザで動くPythonで。最初の6章は無料。全章は2,480円の買い切り。

アプリを無料で開く有料版と無料版の違い