2018/05/29

yield の使い方

Python のキーワード yield の使い方について説明します。


目次


  • yield とは
  • yield でジェネレータを作る
  • yield でコンテキストマネージャを作る
  • yield from でジェネレータを入れ子にする
  • その他の使い方


yield とは


yield は英語で「生み出す」「生む」「起こす」といった意味の単語とのことで、 Python における「 yield 」は、 コードを構成する構成要素(「キーワード」)のひとつで、 yield 式を作るためのもの です。

def get_abc():
    yield 'a'
    yield 'b'
    yield 'c'

print(list(get_abc()))  # => ['a', 'b', 'c']
print(list(get_abc()))  # => ['a', 'b', 'c']

yield 」は、関数(あるいはメソッド)の中にのみ記述することができるもので、 yield が記述された関数をジェネレータ関数化するものです。

ジェネレータ関数 」とは、関数の中でも少し特殊な関数で、通常の関数が 1 つの値を return で返すのに対して、ジェネレータ関数はジェネレータオブジェクトを返します。

ジェネレータオブジェクト 」(もしくは「ジェネレータイテレータ」(ジェネレータイテレータは『ゼニヤッタ・モンダッタ』みたいで語感がいいですね))とは、ジェネレータによって生成されたイテレータオブジェクトです。

イテレータオブジェクト 」とは、 for ループや組み込みの next() 関数に渡せば値を 1 つずつ返してくれるオブジェクトのことです(厳密には、 __iter__() __next__() の 2 つのメソッドを持っていて、 __iter__() メソッドの戻り値が self (自分自身)のオブジェクトです。参考: Iterator Types )。

for item in イテレータ:
    print(item)

まとめます。

  • yield キーワード: 関数の中で yield 式として使われ、関数をジェネレータ関数にするもの。
  • ジェネレータ関数: 「ジェネレータオブジェクト」を返すもの。
  • ジェネレータオブジェクト: ジェネレータ関数の呼び出しによって生成されたイテレータ。


少し余談ですが、このあたりの用語に関してややこしいと私が思うのは、 Python のプログラムの中では「ジェネレータオブジェクト」に「 generator (ジェネレータ)」という表記がされていて、その一方で、 Python.org の公式ドキュメントでは「ジェネレータ関数」のことを「ジェネレータ」と呼んでいるところです。

ジェネレータオブジェクトのことを「ジェネレータ」と呼ぶのであればそれを生成する関数の方は「ジェネレータ関数」、ジェネレータ関数のことを「ジェネレータ」と呼ぶのであればそれが生成するものは「ジェネレータオブジェクト」と呼ぶ、というように、人と話をするときにはどちらかに統一してから話をしないと混乱しそうです。

この節の最後に、公式のドキュメントからジェネレータの説明に関する部分を抜粋してご紹介します。

Generators are a special class of functions that simplify the task of writing iterators. Regular functions compute a value and return it, but generators return an iterator that returns a stream of values.

翻訳: ジェネレータ(訳注: このジェネレータは「ジェネレータ関数」の意味です)はイテレータをシンプルに書けるようにしてくれる特殊な関数です。 通常の関数はある値を計算して返しますが、ジェネレータは値のストリームを返すイテレータを返します

When you call a generator function, it doesn’t return a single value; instead it returns a generator object that supports the iterator protocol.

翻訳: ジェネレータ関数を呼ぶと、単一の値を返す代わりに、イテレータプロトコルをサポートするジェネレータオブジェクトを返します(訳注: これは上の「値のストリームを返すイテレータ」と同じ意味です)。

The big difference between yield and a return statement is that on reaching a yield the generator’s state of execution is suspended and local variables are preserved. On the next call to the generator’s __next__() method, the function will resume executing.

翻訳: yieldreturn 文の大きな違いは、 yield の場合は処理が yield の行に到達したときにジェネレータの実行状態が一時停止されてローカル変数が保持される点です。ジェネレータの __next__() メソッドが次回呼ばれたときにジェネレータ関数は実行を再開します。

参考

概念的な説明だけではわかりづらいので、つづいてサンプルコードを見ながら説明していきます。


yield でジェネレータを作る


yield 式は上述のとおり、関数の中で使われ、関数をジェネレータ関数に変える働きをします。

def gen_etos():
    ETOS = '子丑寅卯辰巳午未申酉戌亥'
    for eto in ETOS:
        yield eto

この関数を呼び出すと、「ジェネレータオブジェクト」(あるいは「ジェネレータイテレータ」)と呼ばれるオブジェクトが返されます。

g1 = gen_etos()

# ジェネレータ関数の戻り値はジェネレータオブジェクト
type(g1)
# => generator

# ジェネレータかどうかのチェックは types の GeneratorType でできる
import types
isinstance(g1, types.GeneratorType)
# => True

ジェネレータオブジェクトは、 for 文または next() 関数で回すことができます。

g1 = gen_etos()
for value in g1:
    print(value)
# =>
# 子
# 丑
# 寅
# 卯
# 辰
# 巳
# 午
# 未
# 申
# 酉
# 戌
# 亥

g1 = gen_etos()
try:
    while True:
        print(next(g1)) 
except StopIteration:
    pass
# =>
# 子
# 丑
# 寅
# 卯
# 辰
# 巳
# 午
# 未
# 申
# 酉
# 戌
# 亥

ジェネレータ関数は同時に複数のジェネレータオブジェクトを返すことができます。それら複数のジェネレータオブジェクトの内部状態は通常(あえて nonlocal 変数を共有するようなことをしなければ)互いに独立です。

g1 = gen_etos()
g2 = gen_etos()
 
for _ in range(3):
    print(next(g1))
# =>
# 子
# 丑
# 寅

# g2 の内部状態は g1 とは独立
for _ in range(2):
    print(next(g2))
# =>
# 子
# 丑

# g1 の内部状態は g2 とは独立
for _ in range(2):
    print(next(g1))
# =>
# 卯
# 辰

説明をシンプルにするために上の gen_etos() は引数を持たないようにしましたが、ジェネレータ関数も通常の関数と同様に引数を受け取ることができます。実用上は引数を渡すケースの方が多いかと思います。

def get_nonblank_lines(path):
    with open(path) as f:
        for line in f:
            if line.strip():
                yield line

g1 = get_nonblank_lines('sample.dat')
for line in g1:
    print(line, end='')

また上のコードではいずれもわかりやすくするためにジェネレータオブジェクトにあえて g1 という名前を付けましたが、実践ではジェネレータオブジェクトには名前を付けずに、次のように for ループの右側で直接ジェネレータ関数を呼び出す形が多いのではないかと思います。

for line in get_nonblank_lines('sample.dat'):
    print(line, end='')

混乱しやすいのですが、ジェネレータ関数そのものがイテレータなのではなく、ジェネレータ関数の戻り値がイテレータであるという点を覚えておく必要があります。

上の説明には「イテレータ」「 for 文」「 next() 関数」ということばが出てきました。これらの意味を押さえておかないと上の説明を完全に理解するのは難しいので、これらの意味がちょっと怪しいぞという方は一度これらのワードを検索するなり書籍にあたるなりしてからこの記事に戻ってきていただくと意味がスッキリとわかるのではないかと思います。

以上で yield 式とジェネレータの基本の説明は終わりです。続いて、 yield の少し応用的なお話に進みます。

yield でコンテキストマネージャを作る


コンテキストマネージャとは with 式に渡して使えるオブジェクトで、コードブロックに一時的なコンテキストを提供するものです。

with open('sample.log', 'a') as f:
    print('これはファイルに書き込まれます。', file=f)

例えば、組み込み関数の open()with 文に渡すとコンテキストマネージャとしてふるまうオブジェクトを返す関数です。

このコンテキストマネージャの仕組みそのものは yield とは独立のものですが、 yield で作るジェネレータ関数を使って、独自のコンテキストマネージャをかんたんに作ることができます。具体的には、コードブロックを挿入したい位置に yield 文を書いたジェネレータ関数を書き、それを @contextlib.contextmanager でデコレートします。

次のコードは print() の出力先を一時的に指定されたファイルに切り替えるコンテキストマネージャを yield を使って定義した例です。

from contextlib import contextmanager
import sys


@contextmanager
def switch_stdout(path):
    try:
        # print() の出力先を指定されたファイルに切り替える
        sys.stdout = open(path, 'a')
        # with のコードブロックの処理がここで実行される
        yield
    finally:
        # print() の出力先を標準出力に戻す
        # sys.__stdout__ はオリジナルの標準出力を格納したもの
        sys.stdout = sys.__stdout__


print('これは標準出力に出ます。')
with switch_stdout('/tmp/sample.log'):
    print('これは標準出力には出ません。')
    print('これも標準出力には出ません。')
print('これも標準出力に出ます。')

このコードを実行すると、 with のコードブロック内の 2 つの print() 関数の文字列は /tmp/sample.log に出力されます。一方、 with のコードブロックの外(=前後)にある print() 関数の文字列は通常どおり標準出力に出力されます。

ちなみに、この switch_stdout() と同等の機能を持つ @contextlib.redirect_stdout が標準ライブラリにすでに用意されています。


上の例のコンテキストマネージャでは yield 式に値を渡していませんが、 yield に値を渡すこともできます。コンテキストマネージャとなるジェネレータ関数の yield に渡された値は、利用時に with 文の as で受け取ることができます。

例えば、次のような中身の bookmarks テーブルを持つ SQLite のデータベースファイルがあるものとします。

url,title 
"https://www.google.co.jp","Google"
"https://www.instagram.com","Instagram"

$ sqlite3 db.sqlite3 >> EOS
CREATE TABLE bookmarks (url text, title text);
INSERT INTO bookmarks VALUES ("https://www.google.co.jp", "Google");
INSERT INTO bookmarks VALUES ("https://www.instagram.com", "Instagram");
EOS

このデータベースのアクセスするためのコンテキストマネージャとして次のようなものを作ることができます。ここで yield に渡されたコネクションオブジェクトは withas で取得・利用することが可能です。

from contextlib import contextmanager
import sqlite3


@contextmanager
def sqlite_conn(path):
    try:
        conn = sqlite3.connect(path)
        # コネクションオブジェクトをコンテキストに渡す
        yield conn
    finally:
        if 'conn' in locals():
            conn.close()

# コネクションオブジェクトを as で受け取る
with sqlite_conn('db.sqlite3') as conn:
    cursor = conn.cursor()
    cursor.execute('SELECT * FROM bookmarks')
    for row in cursor:
        print(row)

このように、通常のジェネレータ関数の yield とコンテキストマネージャの yield は意味合い・使い方が大きく異なるので注意が必要です。ジェネレータ関数の yield 式は「何らかの値を受け取ること」や「繰り返し呼ばれること」が前提で使われてますが、コンテキストマネージャの yield 式は値を返すとはかぎらず、原則一度しか呼ばれません。

他のスクリプト言語に馴染みのある方には、 Python のコンテキストマネージャは、 Ruby であれば「ブロックを受け取るメソッド」、 JavaScript であれば「無名のコールバック関数」に近いものと考えるとわかりやすいかと思います。


yield from でジェネレータを入れ子にする


Python 3.3 以降では yield from 文で、ジェネレータ関数の入れ子がかんたんに実現できるようになっています。

次のジェネレータ関数 gen_numbers_all() は、ジェネレータ関数 gen_numbers_1()gen_numbers_2() のジェネレータオブジェクトをそのまま結合したようなふるまいをします。

def gen_numbers_1():
    for n in 'AKQJ':
        yield n

def gen_numbers_2():
    for n in '98765432':
        yield n

def gen_numbers_all():
    # 他のジェネレータ関数で作られたジェネレータオブジェクトが 
    # yield した値をそのまま yield する
    yield from gen_numbers_1()
    yield from gen_numbers_2()

print(list(gen_numbers_1()))
# =>
# ['A', 'K', 'Q', 'J']

print(list(gen_numbers_2()))
# =>
# ['9', '8', '7', '6', '5', '4', '3', '2']

print(list(gen_numbers_all()))
# =>
# ['A', 'K', 'Q', 'J', '9', '8', '7', '6', '5', '4', '3', '2']

この yield from 式については、ジェネレータオブジェクトの内部状態を変更する send() メソッド等を使った使い方等もう少し複雑な使い方の例も Python.org の公式ドキュメントでは紹介されています。 yield from についてもっと詳しく知りたい方は公式のドキュメントにも目を通してみてください。



その他の使い方


上で見た使い方の他に、 asyncio で利用するコルーチンを定義するためにも yield 式は利用することができます。ただ、この使い方は用途が限られる&私は asyncio を使う必要に迫られず経験が不足しているので、ここで語るのはやめておきます。

気になる方は公式のページ等をご覧ください。


以上、 Python の yield の使い方についてでした。


参考


他サイト

ブログ内

2018/05/23

Python Tips: Python の例外システムを活用したい

Python 3 の例外システムを活用する上で押さえておきたいポイントをまとめてみます。

例外システムに関しては過去に「 Python の例外処理」という記事も書いています。この記事と内容が重複しますが、例外に興味のある方はよろしければそちらもご覧ください。


目次


本記事の目次です。

  • 基本形を押さえる
  • finally を押さえる
  • else を押さえる
  • 組み込みの例外クラスのツリーを押さえる
  • except のパターンを押さえる
  • 例外オブジェクトのアトリビュートを利用する
  • コンテキストマネージャを使う

順に見ていきましょう。

基本形を押さえる


Python 3 の例外処理の基本形は次のとおりです。

import sys

FILE_IN = 'sample.dat'

try:
    # 例外が起こる可能性のある処理
    file = open(FILE_IN)
except Exception:
    # 例外が起こったときの処理
    print('ファイル {} を開くことができません。'.format(FILE_IN), file=sys.stderr)
    sys.exit(1)

キーワード tryexcept を組み合わせて使います。

他の言語を知る方だと try catch のパターンに馴染みのある方が多いでしょうか。 Python の場合は catch の代わりに except を使用します。

except 句は一般に次のいずれかのパターンで書きます。

except [キャッチしたい例外のクラス]:  
except [キャッチしたい例外のクラス] as [キャッチされた例外オブジェクトにつける名前]: 

finally を押さえる


tryexcept の後には finally という句を繋げることができます(厳密にいうと except がなくても大丈夫です)。

import sys

FILE_IN = 'sample.dat'

try:
    # 例外が起こる可能性のある処理
    file = open(FILE_IN)
    file.close()
except Exception:
    # 例外が起こったときの処理
    print('ファイル {} を開くことができません。'.format(FILE_IN), file=sys.stderr)
    sys.exit(1)
finally:
    # 例外の発生有無にかかわらず最後に実行したい処理
    print('finally です。')
# =>
# ファイル sample.dat を開くことができません。
# finally です。

finally 句は例外が発生してもしなくても必ず実行されます。

finally はよくできていて、処理がどの経路を通っても必ず実行され、かつ、なるだけ遅いタイミングで実行されるようになっています。異なる経路のパターン a) - e) をあげて、どのタイミングで finally 句が実行されるのかを以下に見ていきましょう。

a) try の中で例外が発生しなかった場合 → try を抜けるときに実行される:
try:
    print('try です。')
finally:
    print('finally です。')
# =>
# try です。
# finally です。

b) try の中で例外が発生し、 except 句のひとつでキャッチされ、その中で例外があげられなかった場合 → except 句の後に実行される:
try:
    value = 1 / 0
except ZeroDivisionError:
    print('ZeroDivisionError です。')
finally: 
    print('finally です。')
# =>
# ZeroDivisionError です。
# finally です。

c) try の中で例外が発生し、 except 句のひとつでキャッチされ、その中で例外があげられた場合 → except 句内で例外があげられる直前で実行される:
try:
    value = 1 / 0
except ZeroDivisionError as e:
    print('ZeroDivisionError です。')
    raise e
finally: 
    print('finally です。')
# =>
# ZeroDivisionError です。
# finally です。
# Traceback (most recent call last):
#   ...
# ZeroDivisionError: division by zero

d) try の中で例外が発生し、どの except 句でもキャッチされなかった場合 → finally 句が実行された後に例外があがる(公式ドキュメントでは「 re-raised 」という表現が使われています):
try:
    value = 1 / 0
except StopIteration:
    print('StopIteration です。')
finally: 
    print('finally です。')
# =>
# finally です。
# Traceback (most recent call last):
#   ...
# ZeroDivisionError: division by zero

e) try 内で return 文や break 文が来て try 句を抜ける場合 → returnbreak の後に実行される:
def sample_exception():
    try:
        print('try です。')
        return True
    except:
        return False
    finally:
        print('finally です。')

result = sample_exception()
print(result)
# =>
# try です。
# finally です。
# True

このように finally 句はどんな場合でも必ず実行されるので、 try 句の中の処理の成否によらずに必ず実行したい処理――例えばデータベース等の外部リソースの開放等をするのに有用です。

else を押さえる


try except 句の後には else という句を繋げることができます。 else 句は try 句の中で 例外が起こらなかったときにのみ 実行されます。

import sys

FILE_IN = 'sample.dat'

try:
    # 例外が起こる可能性のある処理
    file = open(FILE_IN)
    file.close()
except Exception:
    # 例外が起こったときの処理
    print('ファイル {} を開くことができません。'.format(FILE_IN), file=sys.stderr)
    sys.exit(1)
else:
    # 例外が起こらなかったときの処理
    print('file.closed: {}'.format(file.closed))
# =>
# file.closed: True

例外が起こらなかったときにのみ実行されると聞くと、「 else 句の中身を try の中に書けば else 句は要らないんじゃないか」と思えますが(私は思いました)、 elsetry の中身を最小限に留め思わぬ例外処理が起こってしまうのを防ぐのに有効です。 else 句が便利なパターンとして例えば次のような使い方が考えられます。

try:
    # データベースに変更を加える処理
except:
    # データベースの処理が失敗したのでトランザクションをロールバックする
else:
    # 処理が成功した場合にログを残す

個人的には、この「 try の処理が成功した場合に実行される句」に else という名前を使うのは違和感があります。 then 等の名前の方が直感的でわかりやすい気がしますが、このあたりは「予約語をなるべく少なくする」ことを優先した判断の結果なのですかね。

組み込みの例外クラスのツリーを押さえる


Python 3 には組み込みの例外クラスが多数用意されています。子クラスの例外は親クラスの except 句でキャッチできるので、適切な粒度で例外をキャッチして正しく例外処理を行うために、このツリー構造を押さえて必要があります。

BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- StopAsyncIteration
      +-- ArithmeticError
      |    +-- FloatingPointError
      |    +-- OverflowError
      |    +-- ZeroDivisionError
      +-- AssertionError
      +-- AttributeError
      +-- BufferError
      +-- EOFError
      +-- ImportError
      |    +-- ModuleNotFoundError
      +-- LookupError
      |    +-- IndexError
      |    +-- KeyError
      +-- MemoryError
      +-- NameError
      |    +-- UnboundLocalError
      +-- OSError
      |    +-- BlockingIOError
      |    +-- ChildProcessError
      |    +-- ConnectionError
      |    |    +-- BrokenPipeError
      |    |    +-- ConnectionAbortedError
      |    |    +-- ConnectionRefusedError
      |    |    +-- ConnectionResetError
      |    +-- FileExistsError
      |    +-- FileNotFoundError
      |    +-- InterruptedError
      |    +-- IsADirectoryError
      |    +-- NotADirectoryError
      |    +-- PermissionError
      |    +-- ProcessLookupError
      |    +-- TimeoutError
      +-- ReferenceError
      +-- RuntimeError
      |    +-- NotImplementedError
      |    +-- RecursionError
      +-- SyntaxError
      |    +-- IndentationError
      |         +-- TabError
      +-- SystemError
      +-- TypeError
      +-- ValueError
      |    +-- UnicodeError
      |         +-- UnicodeDecodeError
      |         +-- UnicodeEncodeError
      |         +-- UnicodeTranslateError
      +-- Warning
           +-- DeprecationWarning
           +-- PendingDeprecationWarning
           +-- RuntimeWarning
           +-- SyntaxWarning
           +-- UserWarning
           +-- FutureWarning
           +-- ImportWarning
           +-- UnicodeWarning
           +-- BytesWarning
           +-- ResourceWarning

# ZeroDivisionError は親クラスの ArithmeticError でキャッチできる
try:
    value = 1 / 0
except ArithmeticError as e:
    print('例外が起こりました。')
    print('{}: {}'.format(type(e).__name__, str(e)))
# =>
# 例外が起こりました。
# ZeroDivisionError: division by zero

except のパターンを押さえる


try 句の後に書く except 句はさまざまなパターンで記述することができます。

ひとつの except 句で複数の例外をキャッチする:
try:
    value = 1 / 0
except (OverflowError, ZeroDivisionError) as e:
    print('例外が起こりました。')
    print('{}: {}'.format(type(e).__name__, str(e)))
# =>
# 例外が起こりました。
# ZeroDivisionError: division by zero

複数の except 句をひとつの try 句につける:
try:
    value = 1 / 0
except OverflowError as e:
    print('OverflowError です。')
except ZeroDivisionError as e:
    print('ZeroDivisionError です。')
except Exception as e:
    print('Exception です。')
# =>
# 例外が起こりました。
# ZeroDivisionError: division by zero

except 句を複数連ねるときの注意点は、例外の場合分けを適切にするためには「 小さい例外を前に、大きな例外を後に書かなくてはいけない 」ことです。

上のサンプルではこのとおりに「小さい例外を先、大きな例外を後」にして書いていますが、これを変えて Exceptionexcept 句を先頭に持ってくると、そこですべての例外がキャッチされてしまうので OverflowErrorZeroDivisionErrorexcept 句には到達することがありません。これでは複数の except 句を書いた意味がなくなるので、 except 句は必ず、小さい例外(例外クラスの継承ツリーで枝葉の方にある例外)から先に書く必要があります。

例外オブジェクトのアトリビュートを利用する


例外もひとつのオブジェクトなので、自由にアトリビュートを付けることができます。

例えば、 Requests ライブラリ の例外には、リクエストオブジェクトを格納した request という名前のアトリビュートが付けられています。利用者は except 句の中でそのアトリビュートに自由にアクセスすることができます。

import requests


try:
    requests.get('http://www.yahoo.co.jp', timeout=0.001)
except requests.exceptions.ConnectTimeout as e:
    # ライブラリ側で用意してくれている e.requst アトリビュートが利用できる
    print('URL "{}" へのリクエストがタイムアウトしました。'.format(e.request.url))

規模の大きめのライブラリでは、独自の例外クラスを用意していて、アトリビュートにさまざまな情報を提供していることがあります。ライブラリの独自の例外クラスを取り扱う場合にはどんなアトリビュートがあるのかをチェックしておくとよいでしょう。

コンテキストマネージャを使う


with 文で利用できる Python のコンテキストマネージャは、特定のコードブロックに対して「コンテキスト」を提供できる仕組みです。さまざまな利用方法がありますが、代表的な使い方のひとつに「特定の例外処理パターンを再利用しやすくする」というものがあります。

サンプルとして、 CSV ファイルを読み込んで各行を返すリーダーオブジェクトを提供するコンテキストマネージャの例を見てみましょう。

from contextlib import contextmanager
import csv


@contextmanager
def read_csv(path):
    '''CSV ファイルの読み込みを行うコンテキストマネージャ'''
    try:
        f = open(path)
        reader = csv.reader(f)
        yield reader
    finally:
        if 'f' in locals():
            f.close()


# コンテキストマネージャを使って CSV ファイルを読み込む
with read_csv('sample1.csv') as reader:
    print([row[0] for row in reader])
# 出力例:
# => 
# ['アレックス', 'マーティ', 'メルマン', 'グロリア']

with read_csv('sample2.csv') as reader:
    print([row[0] for row in reader])
# 出力例:
# => 
# ['ポー', 'タイガー', 'ヘビ', 'カマキリ', 'ツル', 'モンキー']

read_csv() を定義することで、 tryfinally を使った例外処理のパターンをシンプルな with 文で再利用できるようになります。

コードの中でよく似た例外処理のパターンが繰り返し出てきた場合は、共通の例外処理パターンをコンテキストマネージャ化することによって、無用なコードの重複を減らすことができます。

...

以上、 Python の例外システムを活用する上で押さえておきたいポイント集でした。

以上のことがひととおりきちんと押さえられれば、 Python の例外処理については「正しく使えている」と自信を持ってよいのではないでしょうか。


参考

2018/05/15

Python にまつわるアイデア: Python のパッケージとモジュールの違い

Python の「パッケージ」と「モジュール」の違いについて説明してみます。

本題に入る前に数点お断りです。

  • この記事は長文です。
  • 記事作成時の Python の最新バージョンは Python 3.6 です。 Python 3.6 の頃の認識にもとづいて書かれています。
  • この記事はある程度調査・確認をした上で書いていますが、私は Python の仕様や Python そのものの開発のプロではありません。あくまでも Python のいちユーザの認識であり間違っている可能性があります(とはいえ、なるべく正確に書こうというモチベーションで書いているので、詳しい方で間違いに気づいた方はご指摘いただけますと幸いです)。

Python の「パッケージ」と「モジュール」の違い


Python のパッケージとモジュールの概念は少し複雑なので、ひとことでかんたんに説明することができません。

次の 2 通りの方法をするのがよいのかなと思います。

  • a) 正確ではないがシンプルな説明
  • b) シンプルではないが(わりと)正確な説明

2 つの説明を持ち出す理由は、数学で「円周率 = 3.14 」と考えることに似ています。

円周率は本来無理数なので「円周率 = 3.14 」という説明は厳密には間違いですが、 3.14 としておいても実用上は問題のない状況がほとんどです。また、より厳密な定義を理解するには無理数等の概念を先に理解するなどの準備が必要です。そのため、円周率については次の 2 通りの説明ができます。

  • e-a) 円周率は 3.14 である
  • e-b) 円周率は 3.14159265358979... と続く無理数である

今回 2 通りの説明をする理由はこれと同じで、 a) の説明は「正確ではないがほとんどの場合はその理解で問題ない」、 b) の説明は「わかりづらいがより正確」、といった違いがあります。

順番に説明していきます。

a) 正確ではないがシンプルな説明

先に、正確ではないがシンプルな説明をしてみます。

Python におけるモジュールとパッケージの説明はそれぞれ次のとおりです。

  • モジュール = ファイル。拡張子が .py (あるいは .pyc 等)の Python ファイルのこと。
  • パッケージ = ディレクトリ__init__.py というファイルを格納したディレクトリのこと。

次のように import 文で読み込むことができる点はパッケージもモジュールも共通です。

それぞれ以下の内容のファイルを準備します。

sample_module.py:
print('これはモジュールです。')

sample_package/__init__.py:
print('これはパッケージです。')

その上でこれらを python -c コマンドで実行します。

$ python -c 'import sample_module'
これはモジュールです。
$ python -c 'import sample_package'
これはパッケージです。

この a) の説明において、パッケージとモジュールの違いは「 モジュールは単一のファイルで、パッケージはディレクトリである 」ということになります。

パッケージはディレクトリなので、他のモジュール(=ファイル)やパッケージ(=ディレクトリ)を中に格納することができます。一方のモジュールはファイルなので、他のモジュールを格納することができません。

$ # パッケージは他のパッケージを格納することができる
$ tree .
└── songohan
    ├── __init__.py
    └── songoku
        ├── __init__.py
        ├── songoten.py
        └── songohan.py
# パッケージの中のパッケージを import することができる
import songohan.songoku
import songohan.songoku.songoten
import songohan.songoku.songohan

a) の説明は以上です。 b) の説明に入る前に、 a) について少し補足をします。

a) の補足: __init__.py を中に持たないディレクトリの場合

Python 3.3 以降では __init__.py ファイルを中に持たないディレクトリも Python パッケージとして認識されるようになりました。

$ # trunks.py は `import vegeta.trunks` でインポートできる
$ tree .    
└── vegeta
    └── trunks.py

__init__.py の無いディレクトリは、パッケージはパッケージだけど少し特殊な「 ネームスペースパッケージ 」(名前空間パッケージ)として扱われます。

ネームスペースパッケージは、異なるパスに置かれた複数のモジュールやパッケージを共通の親パッケージでまとめることができるものです 。例えば、次のように 2 つの異なる場所に chuoku というディレクトリがあった場合、それぞれの親ディレクトリをモジュールの検索パスに追加すれば、これらを共通のネームスペースパッケージとして利用できるようになります。

$ tree /tmp/tokyo/ /tmp/osaka/osaka/
/tmp/tokyo/
└── chuoku
    └── sample1.py
/tmp/osaka/osaka/
└── chuoku
    └── sample2.py

2 directories, 2 files

このことは、次の Python コードで検証することができます。

import sys

# モジュール検索パスに 2 つの chuoku ディレクトリの親ディレクトリを追加する
sys.path.append('/tmp/tokyo')
sys.path.append('/tmp/osaka/osaka')

# ネームスペースパッケージ `chuoku` の下にあるモジュール `sample1` `sample2` を 
# どちらも問題なく import することができる
import chuoku.sample1
import chuoku.sample2

逆に、上の chuoku ディレクトリのうちどちらか一方でも __init__.py ファイルを中に持っていれば、それはネームスペースパッケージではなく通常のパッケージとなります。例えば、 /tmp/tokyo/chuoku の方に __init__.py があればこれは通常のパッケージとなるので、もう一方の /tmp/osaka/osaka/chuoku はパッケージとして読み込めなくなってしまいます。逆もまた然りです。

ちなみに Python の公式ドキュメントでは、ネームスペースパッケージではない通常のパッケージをネームスペースパッケージと区別するために、通常のパッケージを「レギュラーパッケージ」あるいは「トラディショナルパッケージ」ということばで呼んでいます。

この __init__.py を持たないディレクトリが自動的にネームスペースパッケージになると何がうれしいのかと言うと、 規模の大きなパッケージのサブパッケージを別々のディストリビューションパッケージとして配布するのがやりやすくなります

ここで「ディストリビューションパッケージ」というのは配布用にまとめられた単位のパッケージのことを指していて、通常は pip install コマンドの引数として指定するものです。例えば、 Django REST framework というライブラリは、 pip でインストールするときには djangorestframework 、 Python コード内で利用するときは rest_framework という名前でそれぞれ参照・指定します。

pip コマンド:
$ pip install djangorestframework

Python コード内:
from django.contrib.auth.models import User
from rest_framework import serializers

class UserSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = User
        fields = ('url', 'username', 'email', 'is_staff')

この Django REST framework の場合、パッケージ名は rest_framework で、ディストリビューションパッケージ名は djangorestframework 、と言うことができます。

さらにちなみに、 Python 3.3 でこの「 __init__.py が無いディレクトリが自動的にネームスペースパッケージになる」という仕組みが導入される前は、ネームスペースパッケージの仕組みそのものは存在していました。しかし、ネームスペースパッケージ化するには __init__.py を用意してその中に次のようなコードを書く必要があり、これがなかなか面倒臭かったようです。

__path__ = __import__('pkgutil').extend_path(__path__, __name__)
__import__('pkg_resources').declare_namespace(__name__)

このあたりが Python 3.3 で簡単化されて「 __init__.py が無いディレクトリは自動的にネームスペースパッケージになる」という仕組みが導入されました。該当する PEP は PEP 420: Implicit Namespace Packages です。細部に興味のある方は PEP 420 のページを読んでみてください(私は読んでもよくわかりませんが・・・)。


a) の補足は以上です。続いて b) の説明を見ていきましょう。

b) シンプルではないが(わりと)正確な説明

b) における Python のモジュールとパッケージの説明は次のとおりです。

  • モジュール = import 文でインポートすることができるもの
  • パッケージ = モジュールのうち他のモジュールをサブモジュールとして格納したもの

もう少し長めに言い換えます。

  • すべてのパッケージはモジュールである 。数学的な感じで書けば「 Gp ⊂ Gm 」。
  • パッケージとモジュールの共通点は「 import 文でインポートできる 」こと。
  • パッケージとモジュールの違いは「 パッケージには __path__ アトリビュートがありそれを通じてサブモジュールを提供している一方で、モジュールには __path__ が無くサブモジュールを提供していない 」こと。

つまり、パッケージとモジュールは基本的には同じもので、異なるのは「パッケージには __path__ アトリビュートがありそれを通じてサブモジュール(この「サブモジュール」には「サブパッケージ」を含む)を提供していて、モジュールは __path__ アトリビュートが無くサブモジュールを提供していない点」だけです。

この「 __path__ というアトリビュートが重要な仕事をしている」という点は、次のような検証用コードで確認することができます。

2 つのパターンを試してみましょう。ひとつめは「パッケージとして動作する Python ファイル(≒モジュール)」のパターンです。

weird_module.py:
import os

__path__ = (os.path.dirname(__file__), )

weird_module.py ファイルがあるディレクトリに移動して、 Python インタラクティブシェルを起動して次のコードを試してみます。

# どの import 文も問題なく実行できる
import weird_module
import weird_module.weird_module
import weird_module.weird_module.weird_module

weird_module.py は単一の Python ファイルですが、 __path__ アトリビュートを持っており、その値のタプルの要素のひとつとして自分自身の親ディレクトリのパスを持っています。結果として、 weird_module は実体は単一のファイルでありながら、自分自身をサブモジュールとして再帰的に提供できるパッケージとして動作します(あくまでも検証用なので、実用性はありません)。

もう 1 つのパターンを見てみましょう。これは先ほどとは逆で、「 __init__.py ファイルを格納したディレクトリで、本来パッケージとして扱われるはずなのに、中にある .py ファイルをサブモジュールとして提供しないパッケージ」のパターンです。

次のファイル構造を用意しましょう。
$ tree weird_package
weird_package
├── __init__.py
└── child.py

0 directories, 2 files

続いて、 weird_package ディレクトリの下の __init__.py ファイルに次の 1 行のコードを書き込みます。

weird_package/__init__.py:
del __path__

準備ができたら、 weird_package の親ディレクトリに移動し、 Python インタラクティブシェルを起動して次のコードを試してみます。
# weird_package 自体は問題なく import できる
import weird_package

# weird_package/child.py は import できない
import weird_package.child
# =>
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# ModuleNotFoundError: No module named 'weird_package.child'; 'weird_package' is not a package

weird_package のサブモジュールであるはずの childimport することができません。 weird_package ディレクトリは __init__.py ファイルを持っているにもかかわらず、 __path__ が適切に定義されていないので、サブモジュール child を提供していません。

この検証用サンプルでは他にもいろんなことを確認することができますが、これ以上踏み込むことはよしておきます。

b) の説明は以上です。続いて、 a) b) 2 つの説明を比較して、ポイントを見ていきましょう。

a) b) 2 つの説明を比較して

繰り返しになりますが、 b) の説明を読むと a) の「モジュール = ファイル、パッケージ = ディレクトリ」という説明は一部正しいところがあるものの厳密には正しくないということがわかります。

ただ、 a) の説明が完全に間違いかというとそんなことはなくて、実用上は a) の形で理解していても何ら問題がないことがほとんどです。

ちなみに、(私はこのあたりに詳しくはないのですが)、更に厳密に言うなら、 Python のインポートシステムはデフォルトで次の 3 つのインポート用クラスを提供しています。

  • BuiltinImporter: 組み込みモジュールをインポートするためのもの。
  • FrozenImporter: コンパイルされた frozen モジュールをインポートするためのもの。
  • PathFinder: モジュール検索パス上にあるモジュールをインポートするためのもの。

a) の説明は、このうちの PathFinder の挙動の一部を説明したものです。つまり、 a) の説明は PathFinder 以外のインポート用クラスにはあてはまりませんし、さらに PathFinder の場合でも __path__ に手が加えられた特殊なモジュールにはあてはまりません。

他方の b) の説明は、 PathFinder を実際の挙動も含めてより厳密に説明したものです。ただ、こちらもあくまでも PathFinder の説明であり、 BuiltinImporterFrozenImporter にはそのままあてはまりません。

(私の経験上)標準ライブラリ以外のモジュールを利用する場合、ほとんどは PathFinder を利用する形になるので、実用上は b) のところまで押さえられていれば十分ですが、 b) は他のインポートシステムのパターンはカバーできていないため、厳密に言うならこれも不正確ということになるでしょう( b) よりも厳密な c) の説明ができるはずです)。

さらに、 Python のこのインポートシステム自体拡張することができ、開発者が独自に「パッケージとモジュール」の形を決めて実装することもできるようです。独自に拡張されたインポートシステムのことも含めて考えると、 Python の「パッケージとモジュール」の定義・違いをわかりやすく説明するのはさらに難しくなります。

まとめ


最後に、ここまでの内容をかんたんにまとめてみます。おおよそ次のような感じになるでしょうか。

  • シンプルに Python を利用する場合だけなら、 a) の理解で十分
  • 規模の大きなパッケージを開発・管理するような人は、 b) までは理解しておいた方がよさそう
  • Python そのものの開発に関わりたいような人にとっては、 b) の理解でも(おそらく)不十分

世の中のほとんどの人にとって日常生活上は「 円周率 = 3.14 」以上の理解が必要ないのと同じように(本人が知らなくてもその恩恵を受けているという点については無視するとして)、この「 Python のパッケージとモジュールの定義・違い」についても b) 以上の理解を必要とする人はどちらかというと少数派でしょう。

ですので、多くの Python ユーザにとっては、「 厳密に言うなら a) は間違いだけど、ふだんは a) で覚えておいて差し支えない 」程度に認識しておいて、 b) 以上の理解が必要になったときにはその都度調べて確認できるようにしておく、ぐらいがよいのではないかと思います(もちろん、「とにかく使えたら原理はどうだっていい」という考えの人の場合は、まったく理解する必要はありません)。

ちなみに最後にもうひとつ余談ですが、今回 Wikipedia で「円周率」を引いて驚いたのですが、 Wikipedia によると円周率は 2016 年現在で 22 兆桁(!)まで計算されているそうです。


以上、「 Python のパッケージとモジュールの違い」についての説明でした。「わかりやすかったよー」「役に立ったよー」「間違っているよー」という方はコメント等でお知らせいただけるとうれしいです。

以下、参考ページです。いずれも英語のページですが、 Python のパッケージとモジュールについての理解を深めたい方には一読の価値があります。


参考