導入:データ型は「暗記する一覧」ではなく「設計の語彙」である
「Python のデータ型」と検索すると、たいてい int / float / str / list / dict …という一覧表にたどり着きます。それは出発点としては正しい。しかし、現場で人を分けるのは「どんな型があるか」を知っているかではなく、「いつ・なぜ・どの型を選ぶか」を判断軸として持っているかです。
なぜ金額計算に float を使うと本番事故になるのか。なぜ list への in 判定がスケールしないのか。なぜ「初期値に空リストを渡した関数」が、呼ぶたびに過去のデータを引きずるのか。これらはすべて、データ型の「内部の挙動」を理解していれば未然に防げるバグです。逆に言えば、ここを曖昧にしたまま動的型の Python を書くと、テストをすり抜けて本番でだけ壊れます。
本記事は、世界中で読まれている Real Python の "Basic Data Types in Python" が扱う範囲——数値・文字列・真偽値・None・コレクション——を土台にしつつ、そこから先の「本番で効く実装知」まで一気通貫で踏み込みます。具体的には、
- CPython のオブジェクトモデル(なぜ
isと==を混同すると痛い目を見るのか) floatのIEEE 754 の罠と、お金を扱うときの正解(Decimal/ 整数の最小単位)- コレクションの**計算量(Big-O)**に基づく使い分け
- 動的型に静的な安全を与える型ヒントと、
dataclass/Enum/TypedDictによる「型の設計」 - システム境界での検証(Pydantic / marshmallow)
筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装し、また本番二重課金0件を達成したサーバーレス決済プラットフォームで決済信頼性レイヤーを主導してきました。本記事の随所に出てくる「お金は float で持たない」「不正な状態を表現不能にする」といった原則は、すべてその実戦から得たものです。単なる文法の翻訳ではなく、発注して安心して任せられるレベルの設計判断として読んでいただけるよう書きました。
💡 対象バージョン:本記事は Python 3.12 / 3.13 を前提とします(記述の大半は 3.10 以降で有効)。バージョンに依存する機能には、導入されたバージョンを明記します。
0. すべての出発点:Python では「すべてがオブジェクト」である
データ型の話を「型の一覧」から始めると、必ずどこかで詰まります。正しい出発点は、Python のオブジェクトモデルです。ここを 5 分で押さえれば、この後の可変性・コピー・is/== の話がすべて一本の線でつながります。
Python では、整数も文字列も関数もクラスも、**すべてが「オブジェクト」**です。そして、すべてのオブジェクトは 3 つの属性を持ちます。
- アイデンティティ(identity):メモリ上の一意な ID。
id()で取得でき、生存中は不変。 - 型(type):そのオブジェクトが何であるか。
type()で取得。 - 値(value):中身。
ここで決定的に重要なのは、変数は「値」を入れる箱ではなく、「オブジェクトに付けた名札(参照)」だということです。x = 1 は「x という箱に 1 を入れる」のではなく、「1 というオブジェクトに x という名札を貼る」操作です。
a = [1, 2, 3]
b = a # b は「a と同じリスト」に別の名札を貼っただけ(コピーではない)
b.append(4)
print(a) # → [1, 2, 3, 4] ← a も変わる! 同じオブジェクトだから
print(a is b) # → True ← 同一オブジェクト(同じ id)
print(id(a) == id(b)) # → True
この「代入はコピーではなく参照の共有」というモデルこそ、Python の可変オブジェクトにまつわるバグの唯一にして最大の原因です。逆に、ここを理解していれば、後述するすべての落とし穴は「当たり前の帰結」として見えてきます。
💡 JavaScript / Java を書く人へ:感覚は「プリミティブも含めてすべてが参照型」に近いと思ってください。ただし
intやstrは**不変(immutable)**なので、共有していても「中身を書き換えられて壊れる」ことがない——だから安全に見えるだけです。
1. 数値型:int / float / Decimal / Fraction / complex
1-1. int:桁あふれしない任意精度整数
多くの言語の整数は固定幅(32bit / 64bit)で、上限を超えるとオーバーフローします。Python の int は違います。メモリが許す限りいくらでも大きくなる任意精度(arbitrary-precision)整数です。
2 ** 100 # → 1267650600228229401496703205376 桁あふれしない
factorial = 1
for i in range(1, 101):
factorial *= i
len(str(factorial)) # → 158 (100! は158桁)
これは「C言語のような int オーバーフロー由来のセキュリティ脆弱性が、Python では原理的に起きない」という大きな利点です。代償は速度とメモリですが、通常のアプリ開発では問題になりません。
リテラルは基数を変えられ、桁区切りに _ が使えます(Python 3.6+、PEP 515)。可読性のために積極的に使いましょう。
0b1010 # 2進数 → 10
0o17 # 8進数 → 15
0xFF # 16進数 → 255
1_000_000 # → 1000000 (アンダースコアは無視される。可読性のため)
int はビット演算(& | ^ ~ << >>)やビット数取得((255).bit_length() → 8、(7).bit_count() → 3)も備え、フラグ管理や低レベル処理にも耐えます。
1-2. float:速いが「正確ではない」
float は 64bit の IEEE 754 倍精度浮動小数点数です。CPU が直接扱えるので高速ですが、10進数の小数を正確には表現できません。これは Python のバグではなく、2進数で 0.1 を有限桁で表せないという数学的事実です。
0.1 + 0.2 # → 0.30000000000000004 (0.3 ではない!)
0.1 + 0.2 == 0.3 # → False
初学者が必ず驚くこの挙動は、金融・課金・在庫など「正確さが要件」の領域で、そのまま致命的なバグになります。float を比較するときは厳密一致ではなく、許容誤差で比べます(Python 3.5+、PEP 485)。
import math
math.isclose(0.1 + 0.2, 0.3) # → True (相対・絶対許容差で比較)
そしてもう一つの罠が round() です。Python の round() は学校で習う「四捨五入」ではなく、**偶数丸め(banker's rounding/round half to even)**です。統計的な偏りを抑える正しい挙動ですが、知らないと「なぜ切り上がらない?」と混乱します。
round(0.5) # → 0 (0.5 は偶数の 0 へ)
round(1.5) # → 2
round(2.5) # → 2 (2.5 は偶数の 2 へ。3 ではない)
特殊値も覚えておきます。float('inf')(無限大)、float('nan')(非数)があり、nan は自分自身とすら等しくない(nan == nan は False)という、条件分岐を壊しがちな性質を持ちます。nan の判定は必ず math.isnan() を使います。
1-3. 【最重要】お金を float で持ってはいけない — Decimal という正解
ここが、本記事で最も実務に直結するセクションです。金額・通貨・税率・課金額を float で扱った瞬間、あなたのシステムは「ずれる」ことが運命づけられます。
# アンチパターン:float で金額を足し込む
total = 0.0
for _ in range(10):
total += 0.1
print(total) # → 0.9999999999999999 (1.0 にならない)
1 円のずれが、10 万件の決済で積み上がれば、会計が合わなくなり、二重課金や返金漏れの温床になります。正解は decimal.Decimal です。10進数を正確に保持し、丸めモードを明示的に制御できます。
from decimal import Decimal, ROUND_HALF_UP
# ① 必ず「文字列」から生成する(float から作ると誤差を引き継ぐ)
price = Decimal("19.99")
tax_rate = Decimal("0.10")
tax = (price * tax_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
total = price + tax
print(total) # → 20.99 (正確)
# ② float から作ると誤差が入る — 絶対に避ける
Decimal(0.1) # → Decimal('0.1000000000000000055511151231257827021181583404541015625')
Decimal("0.1") # → Decimal('0.1') ← 文字列から作るのが鉄則
実務では、設計レベルでさらに二つの選択肢があります。
| 方式 | 内部表現 | 向くケース | 注意点 |
|---|---|---|---|
Decimal | 10進固定小数 | 為替・税計算・複雑な丸め | DB との往復で型変換を厳密に |
| 整数の最小単位 | 円・銭・cent を整数で | 高頻度・高性能な決済処理 | 表示時に 100 で割る規約をチームで統一 |
たとえば「金額は常にセント(最小通貨単位)の int で持ち、表示の瞬間だけ通貨へ変換する」という設計は、Stripe をはじめ多くの決済システムが採用する定石です。筆者が決済信頼性レイヤーを主導したプラットフォームでは、残高更新を原子的トランザクションと冪等性キーで守り、金額表現を float から完全に排除することで、本番運用で二重課金0件を達成しました。「お金は float で持たない」は、お題目ではなく現場の血で書かれたルールです。
💡
Fractionという選択肢:有理数を「分子/分母」で厳密に保持したいならfractions.Fraction("1/3")が使えます。Fraction(1, 3) * 3 == 1(誤差ゼロ)。確率・比率の累積計算で威力を発揮します。
1-4. complex:科学計算のための複素数
Python は複素数を言語組み込みで持ちます。虚数は j で表します。信号処理・電気回路・フーリエ変換などで使います。
z = 3 + 4j
z.real # → 3.0
z.imag # → 4.0
abs(z) # → 5.0 (複素数の絶対値=大きさ)
1-5. bool:実は int のサブクラス(隠れた罠)
True / False は真偽値ですが、Python では bool は int のサブクラスで、True == 1、False == 0 です。これは便利な反面、静かなバグを生みます。
True + True # → 2 (bool は int なので算術できる)
sum([True, False, True]) # → 2 (イテラブル中の True を数えるイディオム)
isinstance(True, int) # → True ← ここが落とし穴
最後の行が問題です。isinstance(x, int) で「整数だけ通したい」つもりでも、True / False が通り抜けます。境界で整数バリデーションを書くときは type(x) is int で厳密に判定するか、Pydantic のように bool を int から分離する検証器に任せます。
「真偽値らしさ(truthiness)」も重要な概念です。if の条件は bool でなくても評価され、空のコレクション・0・""・None は偽として扱われます。
items = []
if not items: # 空リストは偽 → Pythonic
print("空です")
# アンチパターン: if len(items) == 0: ← 冗長
# アンチパターン: if items == []: ← 型に依存して脆い
⚠️ truthiness の落とし穴:「値が渡されたか」を
if value:で判定すると、0や""や空リストといった正当な値まで「未指定」と誤判定します。「未指定」を区別したいときは、後述のif value is None:を使います。
2. 文字列 str:Unicode コードポイントの不変な並び
2-1. str の本質:不変・Unicode・シーケンス
str は Unicode コードポイントの不変(immutable)なシーケンスです。3 つのキーワードが全てを説明します。
- 不変:一度作った文字列は変更できない。
s[0] = "X"はエラー。「変更」は常に新しい文字列の生成。 - Unicode:
len("こんにちは")は 5(バイト数ではなく文字数)。絵文字や結合文字には注意が必要だが、基本はコードポイント単位。 - シーケンス:インデックス・スライス・イテレーションができる。
s = "Python"
s[0] # → 'P'
s[-1] # → 'n'
s[1:4] # → 'yth' (スライス。元を壊さず新しい str を返す)
s[::-1] # → 'nohtyP' (逆順のイディオム)
len(s) # → 6
文字列リテラルは多彩です。実務で押さえるべきは次の通り。
name = "友田"
# f-string(3.6+):最も推奨される文字列整形
greeting = f"こんにちは、{name}さん"
# f-string の = デバッグ(3.8+):変数名と値を同時に出す
value = 42
print(f"{value=}") # → value=42
raw = r"C:\Users\path" # raw 文字列:\ をエスケープとして扱わない(正規表現・パスで必須)
multi = """複数行を
そのまま書ける""" # 三連クォート
2-2. 「+= でループ連結」をしてはいけない理由
文字列は不変なので、ループ内で += すると毎回新しい文字列を作り直し、最悪 O(n²) になります。正解は str.join() です。
# アンチパターン:O(n²) になりうる
result = ""
for word in words:
result += word # 毎回新オブジェクト生成
# 正解:O(n)。可読性も高い
result = "".join(words)
💡 CPython には
+=を最適化する実装がありますが、実装依存で保証されません。joinは仕様として速く、意図も明確です。「正しいイディオムを選ぶ」のは、性能だけでなく可読性と移植性の問題です。
主要メソッドは「検索・変換・分割・結合・判定」で整理して覚えると応用が効きます。strip() / lower() / upper() / replace() / split() / startswith() / endswith() / find() / format()。大文字小文字を無視した比較は、lower() ではなく casefold()(より厳密な Unicode 折り畳み)を使うのが正解です。
2-3. str と bytes:テキストとバイナリの境界
ここはネットワーク・ファイル・暗号を扱うと必ずぶつかる壁です。
str:人間が読むテキスト(Unicode コードポイント)。bytes:機械が扱う生のバイト列(0–255 の不変な並び)。b"..."で書く。
両者の変換は明示的なエンコーディングを介します。str → bytes は encode()、bytes → str は decode()。文字化けの 9 割は、ここでエンコーディングを暗黙に任せたことが原因です。常に utf-8 を明示しましょう。
text = "日本語"
data = text.encode("utf-8") # → b'\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e'(9バイト)
data.decode("utf-8") # → '日本語'
len(text) # → 3 (文字数)
len(data) # → 9 (バイト数。UTF-8 で日本語は1文字3バイト)
可変なバイト列が必要なら bytearray、メモリコピーなしでバイト列を覗くなら memoryview を使います。大きなバイナリを扱うバックエンドでは、この使い分けがメモリ効率を左右します。
3. None:値が「ない」ことを表す唯一の存在
None は「値がない」「未設定」「該当なし」を表す特別なオブジェクトで、型は NoneType、プログラム全体でただ一つしか存在しません(シングルトン)。だからこそ、None の比較は == ではなく is を使うのが鉄則です。
result = None
if result is None: # 正解:アイデンティティで比較
...
# アンチパターン:== は __eq__ をオーバーライドした型で誤動作しうる
# if result == None:
None を返す関数(DB に該当レコードがない、など)は頻出です。型ヒントでは X | None(Python 3.10+。それ以前は Optional[X])で「X か、なければ None」を表現します。これは「呼び出し側に None チェックを強制する」契約であり、NoneType has no attribute ... という最頻出エラーを静的解析で潰す武器になります。
def find_user(user_id: int) -> "User | None":
...
user = find_user(1)
user.name # mypy / pyright が「None かもしれない」と警告 → 事故を防ぐ
if user is not None:
user.name # ここでは安全
💡 センチネルパターン(上級):
None自体が正当な値になりうる API(例:「キーは存在するが値がNone」)では、Noneを「未指定」の印に使えません。そのときは_MISSING = object()のような専用のセンチネルオブジェクトを作り、if value is _MISSING:で区別します。dict.get(key, default)の内部もこの発想です。
4. コレクション:可変性・順序・ハッシュ可能性・計算量で選ぶ
ここが設計力の差が最も出る領域です。list / tuple / dict / set を「なんとなく」で選ぶのではなく、4 つの軸で判断します。
- 可変か(mutable / immutable):作った後で中身を変えるか。
- 順序を保つか:並び順に意味があるか。
- ハッシュ可能か:
dictのキーやsetの要素にできるか(=不変であることが条件)。 - 計算量(Big-O):その操作はスケールするか。
4-1. list:可変な動的配列
順序があり、変更でき、重複を許す——最も汎用的なコレクション。内部は動的配列なので、**末尾追加は速い(償却 O(1))が、先頭への挿入・削除は遅い(O(n))**です。
nums = [3, 1, 4, 1, 5]
nums.append(9) # 末尾追加:償却 O(1)
nums.insert(0, 2) # 先頭挿入:O(n)(全要素をずらす)
nums.sort() # その場ソート:O(n log n)
9 in nums # メンバーシップ判定:O(n) ← 大きいと遅い
squares = [x * x for x in range(5)] # リスト内包表記:速くて読みやすい
💡 先頭操作が多いなら
collections.deque:両端キュー。appendleft/popleftが O(1)。FIFO キューやスライディングウィンドウでlist.pop(0)を使っているコードは、dequeに替えるだけで劇的に速くなります。Real Python の入門記事は触れませんが、現場では頻出の最適化です。
4-2. tuple:不変で軽量、だから「キー」になれる
tuple は不変な list のようなものですが、用途は明確に異なります。「変わらない・変えてはいけないデータの組」を表すために使います。不変ゆえにハッシュ可能で、dict のキーや set の要素にできます。
point = (35.6895, 139.6917) # 緯度・経度:意味のある固定の組
# point[0] = 0 ← TypeError(不変なので安全)
# 複数戻り値は実はタプル
def divmod_(a, b):
return a // b, a % b # (商, 余り) というタプル
q, r = divmod_(17, 5) # アンパック代入
cache = {(35.68, 139.69): "Tokyo"} # タプルを dict のキーに(list ではできない)
「読み取り専用であることを型で表明したい」「座標やキーのように組で意味を持つ」場面では list ではなく tuple を選ぶ——これだけで、意図が明確になり、誤った変更がコンパイル/実行前に防げます。
4-3. dict:キーと値のマッピング(挿入順を保持)
dict はキーから値への O(1) 平均のマッピング。Python 3.7 以降、挿入順の保持が言語仕様として保証されています(3.6 は CPython の実装詳細でした)。キーは**ハッシュ可能(=不変型)**でなければなりません。
user = {"id": 1, "name": "友田", "role": "engineer"}
user.get("email") # キーがなければ None(KeyError を出さない)
user.get("email", "未設定") # デフォルト付き取得
user.setdefault("tags", []).append("python") # なければ作って操作
# 内包表記とマージ(3.9+ の | 演算子)
squared = {k: v * v for k, v in {"a": 2, "b": 3}.items()}
merged = {"a": 1} | {"b": 2} # → {'a': 1, 'b': 2}
KeyError を避ける .get() / .setdefault()、集計の定番 collections.Counter、キー欠損時に自動生成する collections.defaultdict は、現場の dict 操作を一段クリーンにします。
from collections import Counter, defaultdict
Counter("mississippi") # → Counter({'s': 4, 'i': 4, 'p': 2, 'm': 1})
groups = defaultdict(list)
for word in ["apple", "avocado", "banana"]:
groups[word[0]].append(word) # 'a'/'b' キーを自動生成
4-4. set / frozenset:重複排除と高速メンバーシップ
set は順序を持たない・重複しない・要素はハッシュ可能なコレクション。最大の価値は、**メンバーシップ判定が O(1)**であることと、集合演算が言語レベルで書けることです。
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a & b # 積集合(共通)→ {3, 4}
a | b # 和集合 → {1, 2, 3, 4, 5, 6}
a - b # 差集合 → {1, 2}
a ^ b # 対称差 → {1, 2, 5, 6}
# 重複排除のイディオム
unique = list(set([1, 1, 2, 2, 3])) # → [1, 2, 3](順序は保証されない点に注意)
3 in a # メンバーシップ:O(1) ← list の O(n) と決定的に違う
**「大量データに x in some_list を繰り返す」コードは、some_list を set に変えるだけで O(n²) が O(n) になります。**これは現場で最も費用対効果の高い最適化の一つです。frozenset は不変版で、dict のキーや set の要素にできます。
4-5. 計算量の早見表(これが「使い分け」の核心)
主要操作の平均計算量です。型選択は最終的にこの表に集約されます。
| 操作 | list | deque | dict | set |
|---|---|---|---|---|
| 末尾の追加 | 償却 O(1) | O(1) | — | — |
| 先頭の追加 | O(n) | O(1) | — | — |
| インデックス参照 | O(1) | O(n) | — | — |
| キー / 要素の検索(in) | O(n) | O(n) | O(1) | O(1) |
| キーで値を取得 | — | — | O(1) | — |
| 要素の削除(任意) | O(n) | O(n) | O(1) | O(1) |
判断の指針はシンプルです。「順序つきで反復するなら list、両端操作なら deque、キーで引くなら dict、存在判定と重複排除なら set、変えない組なら tuple」。
5. 可変性が生む本番バグ Top 3(ここで差がつく)
データ型の知識を「事故防止」に変換するセクションです。動的型 Python のバグの大半は、この 3 つに集約されます。
5-1. ミュータブルデフォルト引数(Python 最悪の罠)
関数のデフォルト引数は、関数定義時に一度だけ評価され、呼び出しをまたいで共有されます。可変オブジェクトをデフォルトにすると、前回の呼び出しの結果が次回に漏れます。
# アンチパターン:空リストをデフォルトに
def add_item(item, basket=[]):
basket.append(item)
return basket
add_item("apple") # → ['apple']
add_item("banana") # → ['apple', 'banana'] ← 前回の 'apple' が残る!
正解は None センチネルです。「呼び出しごとに新しいリストを作る」を明示します。
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
この罠は型ヒントでも防げず、レビューでしか止まりません。だからこそ、**「可変デフォルトは即 None センチネル」**を反射で書けるようにしておくのが、プロの最低条件です。
5-2. 共有参照と「浅いコピー」
セクション 0 の「代入はコピーではない」が牙を剥くのがコピーです。copy() やスライスは浅いコピー(shallow copy)——一段目は複製しますが、ネストした要素は共有したままです。
import copy
original = [[1, 2], [3, 4]]
shallow = original[:] # 浅いコピー
shallow[0].append(99)
print(original) # → [[1, 2, 99], [3, 4]] ← 内側が共有されている!
deep = copy.deepcopy(original) # 深いコピー:再帰的に複製。完全に独立
「設定 dict を複製して一部だけ変える」「テストのフィクスチャを使い回す」といった場面で、これは静かにデータを汚染します。ネストがあるなら deepcopy、あるいはそもそも不変型(tuple / frozenset)で設計するのが安全です。
5-3. ハッシュ不可能な型をキーにしようとする
dict のキーや set の要素はハッシュ可能でなければなりません。list / dict / set は可変なのでハッシュ不可、int / str / tuple(中身が全てハッシュ可能なら)はハッシュ可能です。
{[1, 2]: "x"} # TypeError: unhashable type: 'list'
{(1, 2): "x"} # OK:tuple はハッシュ可能
{(1, [2]): "x"} # TypeError:中に list を含む tuple は不可
この性質を逆手に取り、「ハッシュ可能性で『不変であるべき値』を型に表明する」のが上級者の設計です。座標やキーは tuple で、定数の集合は frozenset で持つ——型がそのまま「変えてはいけない」というドキュメントになります。
おまけ:is と ==、そして小整数キャッシュ
==は値の比較(__eq__を呼ぶ)。isはアイデンティティ(同一オブジェクトか)の比較。
is は None / True / False / センチネルにのみ使い、値の比較には絶対に使わないこと。理由は CPython の「小整数キャッシュ」にあります。CPython は -5〜256 の int を再利用するため、次のような実装依存の罠が生まれます。
a = 256; b = 256
a is b # → True (キャッシュされた同一オブジェクト)
a = 257; b = 257
a is b # → False (別オブジェクト。環境により変わる)
257 == 257 # → True ← 値の比較は常に正しい。これを使う
a is b が True になることに依存したコードは、数値が 256 を超えた瞬間に壊れます。**「等しいかは ==、同一かは is」**を機械的に守りましょう。
6. 型の確認:type() ではなく isinstance()、そしてダックタイピング
実行時に型を確認する方法は 2 つあります。
type(42) is int # 厳密に int か(サブクラスは弾く)
isinstance(42, int) # int か、その「サブクラス」か
isinstance(x, (int, float)) # 複数候補のいずれか
原則として isinstance() を使います。継承や抽象基底クラスを尊重するため、より柔軟で正しいからです。ただしセクション 1-5 で見たように、「bool を弾いて int だけ通す」ような厳密判定が必要なときだけ type(x) is int を使います。
さらに Python らしいのは、ダックタイピング——「型が何かではなく、必要な振る舞いを持つか」で判断する考え方です。collections.abc の抽象基底クラスを使うと、具体型に縛られず「反復できるか」「マッピングか」を判定できます。
from collections.abc import Iterable, Mapping
def total(values):
if not isinstance(values, Iterable): # list でも set でも generator でも OK
raise TypeError("反復可能オブジェクトが必要です")
return sum(values)
これは「具体型ではなく抽象(プロトコル)に依存する」という、拡張に強い設計(CLAUDE.md でいう ETC)そのものです。
7. 動的型に「静的な安全」を与える:型ヒント
Python は動的型付けですが、型ヒント(PEP 484)で型を注釈でき、mypy / pyright といった静的解析器が実行前にバグを検出してくれます。型ヒントは実行時には強制されませんが、現代の本番 Python では事実上必須です。
def greet(name: str, times: int = 1) -> str:
return f"Hello, {name}! " * times
# 3.9+ では組み込み型がそのままジェネリックに(PEP 585)
def first(items: list[int]) -> int | None:
return items[0] if items else None
from typing import Final, Literal
MAX_RETRIES: Final = 3 # 再代入を静的に禁止
def set_mode(mode: Literal["r", "w", "a"]) -> None: ... # 取りうる値を型で限定
筆者の現場では、**「any 相当(型を諦める)を禁止し、境界で型を確定させ、CI で型チェックを必須化する」規律を徹底します。動的言語でも、これで「不正な状態を表現不能にする」設計に近づけます。同じ思想を TypeScript で突き詰めた実践は TypeScript 型安全の規律(Zod・NeverError・no-any) にまとめています。言語は違えど、「境界で検証し、内側は型で守る」**という原則は完全に共通です。
8. 標準の型を超えて「自分の型を設計する」
世界最高峰のコードと普通のコードの差は、「組み込み型をそのまま使う」か「ドメインに合った型を設計する」かに表れます。dict で何でも表現するのをやめ、意図を型で語らせます。
8-1. dataclass:構造化データの第一選択
@dataclass(3.7+)は、__init__ / __repr__ / __eq__ を自動生成し、ボイラープレートを消します。frozen=True で不変に、slots=True(3.10+)でメモリ効率と速度を改善できます。
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Money:
amount: int # 最小通貨単位(cent)で持つ
currency: str = "JPY"
@dataclass
class Order:
id: int
items: list[str] = field(default_factory=list) # ← 可変デフォルトの正しい書き方
m = Money(1999) # 不変なのでハッシュ可能・安全に共有できる
field(default_factory=list) に注目してください。これはセクション 5-1 のミュータブルデフォルト問題を、dataclass が正しく解決する公式の作法です。
8-2. Enum:「文字列の定数」をやめる
状態や種別を生の文字列で持つと、"acitve" のようなタイポが実行時まで露見しません。Enum(StrEnum は 3.11+)で取りうる値を閉じた集合にすれば、不正値を型で排除できます。
from enum import StrEnum
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
status = OrderStatus.PAID
status == "paid" # → True(StrEnum は str でもある)
# OrderStatus("unknown") → ValueError(不正値を即座に弾く)
8-3. NamedTuple / TypedDict:軽量な型付け
NamedTuple:不変・タプル互換で、フィールドに名前をつけたいとき。TypedDict:JSON のように「dictの形」を型で表明したいとき(API レスポンスの型付けに最適)。
from typing import NamedTuple, TypedDict
class Point(NamedTuple):
x: float
y: float
class UserDict(TypedDict):
id: int
name: str
email: str | None
「dict を渡し回す」コードを dataclass / TypedDict に置き換えるだけで、IDE 補完が効き、タイポが消え、リファクタが安全になります。これは保守性(maintainability)への直接投資です。
9. システム境界では型を「検証」する:Pydantic / marshmallow
最後に、これまでの知識を本番アーキテクチャに接続します。型ヒントは「内部のコードを守る盾」ですが、実行時には強制されません。だから、HTTP リクエスト・外部 API レスポンス・環境変数・メッセージキューといったシステム境界の外から来るデータは、必ず実行時に検証しなければなりません。「外から来るデータを信頼しない」——これがセキュアなバックエンドの第一原則です。
この境界に立つのが Pydantic v2 と marshmallow です。
from pydantic import BaseModel, EmailStr, Field
class CreateUser(BaseModel):
name: str = Field(min_length=1, max_length=50)
email: EmailStr
age: int = Field(ge=0, le=150)
# 不正な dict(外部入力)を渡すと ValidationError で堰き止める
user = CreateUser.model_validate({"name": "友田", "email": "a@example.com", "age": 30})
ここまで読んだあなたは、もう理解しているはずです。Pydantic / marshmallow は、本記事で学んだ「型」を実行時の契約に変える装置です。int の範囲、str の長さ、None 許容(Optional)、ネストした構造——すべてを宣言的に検証し、信頼できるデータだけを内側へ通す。動的言語の柔軟さを保ちながら、静的言語並みの堅牢さを境界で獲得する、というのが現代 Python バックエンドの設計の到達点です。
- 型ファーストの境界検証 → Pydantic v2 実践ガイド
- ORM / フレームワーク非依存のシリアライズ/検証 → marshmallow 実践ガイド
- Web フレームワーク層での入力検証 → FastAPI 本番運用ガイド と FastAPI のリクエスト検証
- 永続化層の型安全 → SQLAlchemy 2.0 実践ガイド
- お金と冪等性の設計 → 決済の二重課金を防ぐ冪等性設計
まとめ:データ型を「制約」として設計する
Python のデータ型は、暗記すべき一覧ではなく、**「正しさ・速さ・安全を、コードの構造で表現するための語彙」**です。本記事の要点を、判断軸として持ち帰ってください。
- すべてはオブジェクト。変数は参照。だから可変性・
is/==・コピーの挙動はすべて一本の線でつながる。 - お金は
floatで持たない。Decimal(文字列から生成)か整数の最小単位で。これは現場の鉄則。 - コレクションは計算量で選ぶ。存在判定と重複排除は
listではなくset/dict(O(1))。 - 可変性のバグを反射で防ぐ。ミュータブルデフォルトは
Noneセンチネル、ネストはdeepcopyか不変型。 - 型を設計する。
dataclass/Enum/TypedDictで不正な状態を表現不能にし、境界は Pydantic / marshmallow で検証する。
「動的型だから雑でいい」のではありません。動的型だからこそ、型に対する深い理解と規律が、本番品質を分けます。 筆者は、この原則を Python / Flask / SQLAlchemy のバックエンドや、本番二重課金0件を達成した決済基盤で実践してきました。データ型を「制約として設計する」発想こそが、テストをすり抜けるバグを未然に消し、変更に強いコードベースを作ります。
よくある質問(FAQ)
Q. Python のデータ型は結局いくつ覚えればいい?
実務で頻出するのは 数値(int / float / Decimal)・文字列(str / bytes)・真偽値(bool)・None・コレクション(list / tuple / dict / set) の約 10 種です。まずこれらの「可変性・順序・ハッシュ可能性・計算量」を押さえれば、9 割の場面に対応できます。
Q. list と tuple はどう使い分ける?
変更するなら list、変更しない(してはいけない)組なら tuple です。tuple は不変ゆえにハッシュ可能で、dict のキーや set の要素にできます。座標・複数戻り値・固定レコードは tuple、要素を追加・削除する集まりは list が自然です。
Q. なぜ 0.1 + 0.2 が 0.3 にならないの? バグ?
バグではありません。float は 2 進数の浮動小数点(IEEE 754)で、0.1 を有限桁で正確に表現できないためです。比較は math.isclose()、金額計算は Decimal か整数の最小単位を使います。
Q. is と == はどちらを使うべき?
値が等しいかは ==、同一オブジェクトかは is です。is は None / True / False / センチネルの判定にのみ使い、数値や文字列の比較には使いません(CPython の小整数・文字列キャッシュに依存して壊れます)。
Q. 型ヒントは実行時に効くの? 書く意味はある?
実行時には強制されません(無視されます)が、mypy / pyright が実行前にバグを検出してくれるため、本番コードでは事実上必須です。実行時に外部入力を検証したいなら、Pydantic や marshmallow を併用します。