01オブジェクト指向とは
Java 版で述べたとおり、オブジェクト指向は Simula67 に始まる考え方で、世界をもの(object) の 集まりとして捉え、もの同士のメッセージの受渡しで現象が進むと考える。オブジェクトは 状態・メソッド・計算を実行する主体の 3 つから成る、という見方も同じである。
Python はこの考え方を徹底していて、整数も関数もクラスも、すべてがオブジェクトである。 一方で「計算を実行する主体」については独特の事情があり、それがこの資料の後半の主題になる。
この資料は Java 版・JavaScript 版 と 3 つで対になっています。 同じ題材を 3 つの言語でたどると、「同時に動く」ということの意味が言語ごとに違うことがはっきりします。
02Python のオブジェクト
Python でオブジェクトの状態は、既定では辞書(__dict__) に入っている。
外から自由に足せてしまうのは、Java とも JavaScript の #private とも違う点である。
class Tree:
def __init__(self):
self.status = '立っている'
def fall(self):
self.status = '倒れた'
t = Tree()
t.fall()
print(t.status) # 倒れた
print(t.__dict__) # {'status': '倒れた'}
t.color = '緑' # 定義していない属性も後から生やせる
「外から触ってほしくない」ことを示すには、名前の先頭にアンダースコアを付ける習慣がある。
アンダースコア 2 つにすると名前マングリングが働き、クラスの外からは
_クラス名__名前 でしか触れなくなる。ただしこれも「隠す」のではなく
「うっかり衝突させない」ための仕組みで、Java の private のような強制力はない。
class Tree:
def __init__(self):
self._status = '立っている' # 「触らないでね」という合図(強制力なし)
self.__secret = 42 # _Tree__secret に名前が変わる
t = Tree()
t._status # 触れてしまう
t.__secret # AttributeError
t._Tree__secret # 42 ← 実は触れる
03class 構文
Python にはメソッドの多重定義(オーバーロード)が無い。同じ名前で 2 回定義すると 後の定義が前を上書きする。これは JavaScript と同じ事情である。
class Calc:
def add(self, a, b): ...
def add(self, a, b, c): ... # ← 前の add を消してしまう
引数の数で使い分けたいときは、既定値・可変長引数で書く。型で分けたいときは
functools.singledispatch がある。
from functools import singledispatch
@singledispatch
def draw(x): return f'なにか: {x}'
@draw.register
def _(x: int): return f'整数: {x}'
@draw.register
def _(x: str): return f'文字列: {x}'
draw(3) # 整数: 3
draw('あ') # 文字列: あ
| Java | JavaScript | Python | |
| 私有 | private(強制) | #x(強制) | _x は合図のみ |
| 多重定義 | あり | なし | なし(singledispatch で代用) |
| 多重継承 | なし(インタフェースのみ) | なし(mixin で代用) | あり |
| インタフェース | あり | なし | Protocol/ダックタイピング |
継承
継承は括弧に基底クラスを並べて書く。super() は「自分の次に呼ぶべきもの」を返す。
Java の super が親クラスそのものを指すのに対し、Python の super() は
次に述べる MRO の次の要素を指す。多重継承があるためで、ここが決定的に違う。
class Shape:
def __init__(self, name):
self.name = name
def draw(self):
return f'{self.name} を描く'
class Square(Shape):
def __init__(self, side):
super().__init__('正方形')
self.side = side
def draw(self):
return super().draw() + f'(一辺 {self.side})'
print(Square(3).draw()) # 正方形 を描く(一辺 3)
多重継承と MRO
Python は多重継承を許す。すると「同じメソッドを複数の親が持っていたらどれが呼ばれるか」 という問題が起きる。Java がインタフェースしか許さなかったのは、まさにこれを避けるためであった (Java 版「継承の問題点」参照)。
Python の答えが MRO(Method Resolution Order、メソッド解決順序) である。 C3 線形化という算法でクラスを一列に並べ、その順に探す。
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass # ダイヤモンド継承
D.__mro__
# (D, B, C, A, object)
規則は 3 つである。①自分が先、②基底クラスを書いた順を守る、 ③子より先に親が来てはいけない。この 3 つを同時に満たす並びが C3 線形化で、 満たす並びが存在しないクラス定義は定義した時点でエラーになる。
class X: pass
class Y: pass
class P(X, Y): pass
class Q(Y, X): pass
class R(P, Q): pass
# TypeError: Cannot create a consistent method resolution order (MRO) for bases P, Q
super() はこの並びを 1 つずつ進む。だから多重継承では、
super() が親とは限らない。次のデモで実際に並びを作って確かめられる。
Try it: computing the MRO
Swap the base order or toggle which classes define draw().
オーバーライドと動的束縛
メソッドを探す道すじは MRO そのものである。実体のクラスから MRO を順にたどり、 最初に見つかった定義が呼ばれる。Java の継承の鎖が「一本道」だったのに対し、 Python では並び替えられた一列を進む、と考えればよい。
class Base:
def draw(self):
return 'Base.draw'
class Mixin:
def draw(self):
return 'Mixin.draw → ' + super().draw() # 次の MRO 要素へ渡す
class Widget(Mixin, Base):
pass
Widget().draw() # Mixin.draw → Base.draw
Widget.__mro__ # (Widget, Mixin, Base, object)
Mixin は Base を継承していないのに super().draw() が
Base.draw に届く。誰が次に来るかは、そのクラス単体ではなく、
最終的にどう組み合わされたかで決まる。これが Python の多重継承の強みであり、
同時に読みにくさの原因でもある。
04Python の並行性
ここからが本題である。Python には本物のスレッド(OS のスレッド)がある。
Java と同じように threading.Thread で作れるし、いつ割り込まれるか分からない
プリエンプティブな切り替えも起きる。
ところが GIL(Global Interpreter Lock、グローバルインタプリタロック) があるため、 同時に Python のバイトコードを実行できるスレッドは常に 1 つだけである。 スレッドは存在するのに、計算は並列にならない。
| Java | JavaScript | Python | |
| スレッド | ある | ない(Worker は別) | あるが GIL で1つずつ |
| 切り替わり方 | プリエンプティブ | 協調的(await) | プリエンプティブ |
| 切り替わる場所 | どこでも | await だけ | バイトコードの切れ目 |
| 競合状態 | 起きる | await をまたぐと起きる | 起きる |
| CPU を使う処理が速くなるか | なる | Worker ならなる | ならない(multiprocessing が要る) |
つまり Python のスレッドは「速くするため」ではなく「待ち time を重ねるため」にある。 通信やファイル入出力の待ちの間は GIL が解放されるので、そこは重ねられる。
GIL とは
GIL は Python インタプリタ全体に 1 つだけある鍵である。バイトコードを実行するには この鍵を持っていなければならず、持てるのは 1 スレッドだけである。 鍵は次のときに手放される。
- 一定時間が経ったとき — 既定は 5 ミリ秒(
sys.getswitchinterval()) - 入出力で待つとき — ファイル・通信・
time.sleepなど - C 拡張が明示的に手放したとき — NumPy の重い計算などはここで手放す
import sys
sys.getswitchinterval() # 0.005
sys.setswitchinterval(0.001) # 短くすると切り替わりが増える(速くはならない)
だから CPU をひたすら使う処理をスレッドで 4 本に分けても、合計時間はほぼ変わらない。 むしろ切り替えのぶん少し遅くなる。一方 待ちが中心の処理なら、待っている間に 他のスレッドが動けるので、本数に応じて短くなる。次のデモで両方を試せる。
Python 3.13 から、GIL を無くしたフリースレッド版(PEP 703)が実験的に選べるようになりました。 将来はこの前提が変わる可能性がありますが、当面は標準の版に GIL がある前提で考えます。
Try it: what the GIL looks like
Four threads; dark bars are real execution.
05threading
スレッドの作り方は Java とよく似ている。
import threading, time
def worker(name):
for i in range(3):
print(name, i)
time.sleep(0.1) # ここで GIL を手放す
ts = [threading.Thread(target=worker, args=(f'T{i}',)) for i in range(3)]
for t in ts: t.start()
for t in ts: t.join() # 終わるまで待つ
Java 版の WireFrame の例のように、各オブジェクトに自分のスレッドを持たせることもできる。
class Rotator(threading.Thread):
def __init__(self, shape):
super().__init__(daemon=True)
self.shape = shape
def run(self): # start() から呼ばれる
while True:
self.shape.rotate()
time.sleep(1 / 30)
競合状態
Python でも count += 1 は不可分ではない。バイトコードに分解すると
複数の命令になり、その切れ目で他のスレッドに切り替わりうる。
import dis
def inc():
global count
count += 1
dis.dis(inc)
# LOAD_GLOBAL count ← 読む
# LOAD_CONST 1
# BINARY_OP += ← 足す
# STORE_GLOBAL count ← 書く
「読む」と「書く」の間で切り替わると、古い値をもとに書き戻してしまい、更新が消える。 Java 版とまったく同じ現象である。
count = 0
def add_many():
global count
for _ in range(100000):
count += 1
ts = [threading.Thread(target=add_many) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(count) # 200000 にならないことがある
直し方も同じで、読んでから書くまでを他に渡さないようにする。Python では
threading.Lock を使い、with 文で囲むのが定石である。
lock = threading.Lock()
def add_many():
global count
for _ in range(100000):
with lock: # Java の synchronized に相当
count += 1
Try it: switching between bytecodes
Two threads run count += 1 a hundred times each.
06同期をとる
Python の threading には、Java 版で扱った道具がひととおり揃っている。
| 道具 | 役割 | Java でいうと |
Lock | 1 つだけ通す | synchronized |
RLock | 同じスレッドなら入れ子で取れる | 再入可能ロック |
Semaphore(n) | n 個まで通す | セマフォ |
Event | 合図を待つ | wait/notify |
Condition | 条件が整うまで待つ | wait/notifyAll |
Barrier | 全員そろうまで待つ | CyclicBarrier |
セマフォの P/V は、Python では acquire()/release() である。
sem = threading.Semaphore(2) # 資源は 2 つ
with sem: # acquire()(=P)、抜けるとき release()(=V)
... # ここを通れるのは同時に 2 スレッドまで
有限バッファ問題
Java 版と同じ生産者・消費者を書く。Python には queue.Queue という
スレッド安全な有限バッファが最初から用意されていて、
empty/full/mutex の 3 つのセマフォを自分で書く必要がない。
import queue, threading, time
buf = queue.Queue(maxsize=6) # ← 有限バッファ
def producer():
for i in range(1, 100):
buf.put(i) # 満杯なら空くまで待つ
print('置いた', i)
time.sleep(0.2)
def consumer():
while True:
x = buf.get() # 空なら来るまで待つ
print('取った', x)
buf.task_done()
time.sleep(0.3)
threading.Thread(target=producer, daemon=True).start()
threading.Thread(target=consumer, daemon=True).start()
中身は Java 版で書いたのと同じで、Condition を使って
「空きが出るまで待つ」「品物が来るまで待つ」を実現している。自分で書くならこうなる。
class BoundedBuffer:
def __init__(self, n):
self.buf, self.n = [], n
self.cv = threading.Condition()
def put(self, x):
with self.cv:
while len(self.buf) == self.n: # while であって if ではない
self.cv.wait()
self.buf.append(x)
self.cv.notify_all()
def get(self):
with self.cv:
while not self.buf:
self.cv.wait()
x = self.buf.pop(0)
self.cv.notify_all()
return x
待つ条件を if ではなく while で書くのが要点である。
起こされた時点で条件がまだ成り立っているとは限らないためで、これは Java でも同じである。
Try it: queue.Queue(maxsize=6)
The code above, running.
07multiprocessing
CPU を使う処理を本当に速くしたいなら、プロセスを分ける。 プロセスごとに Python インタプリタが 1 つずつ動くので、GIL も 1 つずつになり、 本当に並列に実行される。
from concurrent.futures import ProcessPoolExecutor
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
with ProcessPoolExecutor() as ex:
print(list(ex.map(fib, [32, 32, 32, 32]))) # 4 コアならほぼ 1 個分の時間
代わりにメモリを共有しない。やりとりする値は pickle で直列化されて
プロセス間を渡るので、渡せない値(ラムダ式、開いたファイルなど)は使えず、
大きなデータのやりとりには時間がかかる。これは Web Worker の
postMessage とまったく同じ事情である。
# 共有したいときは専用の入れ物を使う
from multiprocessing import Value, Lock
count = Value('i', 0) # 共有メモリ上の整数
lock = Lock()
with lock:
count.value += 1 # ここでもロックは要る
08asyncio
もう一つの道具が asyncio である。こちらはスレッドを使わず、
1 本の流れの中で待ち合わせ点を作って譲り合う。JavaScript のイベントループと
同じ考え方で、実際 async/await という書き方まで同じである。
import asyncio
async def worker(name):
print(name, '開始')
await asyncio.sleep(1) # ここで他に譲る(1秒間ずっと譲っている)
print(name, '終了')
async def main():
await asyncio.gather(worker('A'), worker('B'), worker('C'))
asyncio.run(main())
# 3 つ同時に開始し、約 1 秒後に 3 つ同時に終わる
ここでも競合は起きる。await をまたいで「読む→書く」をすると、
JavaScript 版で見たのと同じように更新が消える。
count = 0
async def inc():
global count
v = count # 読む
await asyncio.sleep(0) # ← ここで譲る
count = v + 1 # 古い v をもとに書く
asyncio.run(asyncio.gather(inc(), inc()))
# count は 2 ではなく 1
直すには asyncio.Lock を使う。threading.Lock とは別物なので
取り違えないこと(threading.Lock をイベントループの中で使うと、
待っている間ループ全体が止まってしまう)。
lock = asyncio.Lock()
async def inc():
global count
async with lock:
v = count
await asyncio.sleep(0)
count = v + 1
Try it: the asyncio event loop
Three coroutines under gather.
実行中
実行可能キュー
待ち(sleep)
09どれを使うか
3 つの道具の使い分けは、処理が何を待っているかで決まる。
| 処理の性質 | 例 | 使うもの | 理由 |
| 入出力待ちが中心 | Web API を何百回も叩く、ファイル読み書き | asyncio または threading | 待っている間 GIL が空くので重ねられる |
| CPU を使い切る | 画像処理、数値計算、探索 | multiprocessing | GIL があるためスレッドでは速くならない |
| CPU だが C 拡張の中 | NumPy、Pillow の重い処理 | threading でも可 | C 側が GIL を手放すため |
| 待ちが多く、本数も多い | 同時接続を何千も抱える | asyncio | スレッドより1本あたりが軽い |
asyncio と threading はどちらも入出力待ちに使えるが、性格が違う。
asyncio は譲る場所が await に限られるので、どこで割り込まれるかが
コードから読める。threading はどこでも割り込まれる代わりに、
既存の同期的なコードをそのまま動かせる。次のデモで、条件を変えて所要時間を比べられる。
Try it: which one is faster
Pick the kind of work and the number of tasks.
10課題
課題 1 次のクラス定義について、D.__mro__ を手で求めよ。
C3 線形化の 3 つの規則のどれがどこで効いているかを説明すること。求まったら MRO のデモで確かめよ。
class A: pass
class B(A): pass
class C(A): pass
class D(C, B): pass # ← B, C ではなく C, B
課題 2 count += 1 を 10 万回ずつ 2 スレッドで実行したとき、
なぜ 20 万にならないことがあるのか。dis の出力のどの命令とどの命令の間で
切り替わると更新が消えるのかを示して説明せよ。
課題 3 fib(32) を 4 個計算する処理を、①素直に順番に ②ThreadPoolExecutor
③ProcessPoolExecutor の 3 通りで書き、所要時間を測って比べよ。
②が速くならない理由を GIL の言葉で説明すること。
課題 4 上の BoundedBuffer の while を if に書き換えると、
どんなときに壊れるか。生産者 2 人・消費者 1 人の場合で、誰がいつ起こされるかを追って示せ。
課題 5 asyncio で、同時に 3 本までしか走らせない
async def limit(n, coros) を asyncio.Semaphore を使って書け。
JavaScript 版の課題 3 と同じ問題を、Python で解くとどうなるか比べよ。
The same material in the other two languages: Java edition / JavaScript edition