一覧へ戻る

Python のオブジェクト

オブジェクトとは

Hello World とは、プログラミング言語の世界における伝統的な初歩的なプログラムです。多くの Python 入門者はまずインタプリタを起動し次のように入力することで、この世界に足を踏み入れてきたはずです。

>>> 'Hello World'
'Hello World'

非常に初歩的なプログラムですが、このプログラムはオブジェクト指向プログラミングの世界におけるオブジェクトの概念を理解する上で重要なプログラムです。

一見すると(echo コマンドのように)インタプリタが呼応して'Hello World'という文字列を返しているだけのように見えます。しかし事態はそう単純な話ではありません。この裏側でインタプリタは、'Hello World'という値をもつ文字列型のオブジェクトを生成した後、そのオブジェクトの文字列表現を標準出力しており、我々はその結果を目にしています。

Python では、全ての要素がオブジェクトとして表現されます。上でみた文字列や整数、浮動小数点数、リスト、辞書、関数、クラス、例外、モジュールなどなど全てがオブジェクトです。

オブジェクトとは値(value)と型(type)、識別子(id)を持つ透明な箱のようなものです。

透明であるため、我々は自由に箱の中身を覗くことができます。

---------------
|             |
|             |
|    value    |
|             |
| type        |
---------------

オブジェクトは通常リテラル1を用いて生成します。

>>> 'Hello World'
'Hello World'
---------------
|             |
|             |
| Hello World |
|             |
| type: str   |
---------------
>>> 1991
1991
---------------
|             |
|             |
|    1991     |
|             |
| type: int   |
---------------

オブジェクトの型を確認するには、組み込み関数 type() を利用します。

>>> type('Hello World')
<class 'str'>
>>> type(1991)
<class 'int'>
>>> type(type)
<class 'type'>

また、組み込み関数 id() はオブジェクトの識別子を返します。

>>> id('Hello World')
4379060208
>>> id(1991)
4372760080

識別子はオブジェクトに対して一意に割り当てられ、実行中は変更されることはありません。

加えて識別子はメモリ上でのオブジェクトの位置を表します。そのため ctypes.cast を利用して、直接取得したメモリアドレスへのアクセスすることも可能です。

>>> import ctypes
>>> obj = 'Hello World'
>>> ctypes.cast(id(obj), ctypes.py_object).value
'Hello World'

⚠ Warning
Python では直接的なメモリアクセスは推奨されません。上のコードを書くようなことはまずありません。

変数は箱ではない

特定の文脈では変数に対して「値を格納する箱」のように説明される場合がありますが、こと Python の文脈においては、このイメージは不正確です。

>>> var = 1991
>>> var
1991

正しくは、変数はオブジェクトに紐づいたタグのようなものです。

x 誤ったイメージ: varという名前の箱に1991という値が入っている
----------------
|              |
|              |
|     1991     |
|              |
|  var         |
----------------

o 正しいイメージ: 名前varは数値オブジェクト1991を指している
----------------
|              |
|              |
|     1991     |
|              |
| type: int    |-----[var]
----------------

変数を格納する箱と表現した場合、以下のコードの説明がつきません。

>>> a = [1, 2, 3]
>>> b = a
>>> a += [4]
>>> b
[1, 2, 3, 4]  # expected [1, 2, 3]

より正確に表現すると、変数はオブジェクトに対する参照です。作ったオブジェクトがメモリの波に飲み込まれれる前に、変数を通してオブジェクトへの参照を保持する、つまり繋がりを持たせることで、その後もオブジェクトを利用できるようにしているのです。

ℹ Note
例えば C++や Rust などのように、変数を値を格納する箱と表現できる言語も存在します。。これらの言語では変数はメモリ上の特定の位置を表します。(Rust のプリミティブ型はこの限りではありません)


前節で、オブジェクトが保存されているメモリアドレスへ直接アクセスする方法を紹介しました。

>>> import ctypes
>>> obj = 'Hello World'
>>> id(obj)
4379060208
>>> ctypes.cast(4379060208, ctypes.py_object).value
'Hello World'

上記の例では obj という変数を利用してオブジェクトへの参照を保持していますが、以下のようにオブジェクトへの参照が保持されず、オブジェクトへ到達不可能になると、オブジェクトはガベージコレクト(後述)され、メモリから消去されます。

解放されたオブジェクトのメモリを参照しても期待される出力は得られません。

>>> import ctypes
>>> id('Hello World')
4379060208
>>> ctypes.cast(4379060208, ctypes.py_object).value
b'e eej¡jFdS'

2 行目でリテラルを利用して'Hello World'という値をもつ文字列オブジェクトが生成されましたが、直前の例と異なり参照が保持されないため、以降このオブジェクトに対して操作することは不可能です。オブジェクトはガベージコレクトされ、メモリが解放されたため、元々の位置にあったデータは上書きされています。

ガベージコレクションと参照カウント

Python では、オブジェクトが不要になった時点で自動的にメモリを解放します。この仕組みをガベージコレクションと呼びます。

正確には、オブジェクトへ到達不可能になった時点で、そのオブジェクトはガベージコレクションの対象となります。

到達可能性の判断には、参照カウントという概念が利用されます。一般的に、オブジェクトへの参照が増えると参照カウントが増え、参照が減ると参照カウントが減ります。参照カウントの値が 0 になった時点で、そのオブジェクトはガベージコレクションの対象となるわけです。

sys.getrefcount 関数を利用すると、オブジェクトへの参照カウントを取得できます。

>>> import sys
>>> obj = 'Hello World'
>>> sys.getrefcount(obj)
2
>>> obj2 = obj
>>> sys.getrefcount(obj)
3
>>> obj2 = None
>>> sys.getrefcount(obj)
2

コード 4 行目にて、文字列オブジェクト'Hello World'に対して新たな参照 obj2 を作成したので、参照カウントが 1 増えたことが確認できます。またコード 6 行目で obj2 は別のオブジェクト None を参照するようになったため、オブジェクトへの参照カウントは 1 減ることを確認してください。

ℹ Note
最初の sys.getrefcount(obj)の返り値が 2 であったことに驚いた方も多いでしょう。これは、sys.getrefcount()関数の引数に渡したオブジェクトへの参照が関数内部で一時的に作成されるためです。

参照カウントが 0 になった時点で、オブジェクトはガベージコレクションの対象となります。実際にガベージコレクトされるタイミングは、Python インタプリタの実装に依存しますが、 weakref.finalize を利用すると、ガベージコレクト時に呼び出されるコールバック関数を登録できます。

>>> import weakref
>>> obj = {1, 2, 3}
>>> weakref.finalize(obj, lambda: print('garbage collected!'))
>>> obj = None
garbage collected!

3 行目で、集合オブジェクト3に対してガベージコレクト時のコールバック関数が登録されました。その後、4 行目で3への参照を失ったことでガベージコレクトされ、コールバック関数が実行されました。

また参照カウントのみが到達可能性の判定に使われるわけではなく、1 以上の参照カウントがあってもそれが循環参照であり、どのみち到達出来ない場合はガベージコレクトの対象になります。

>>> import weakref
>>> class A:
...     def __init__(self):
...         self.b = None
...
>>> class B:
...     def __init__(self):
...         self.a = None
...
>>> obj1 = A()
>>> obj2 = B()
>>> obj1.b = obj2
>>> obj2.a = obj1
>>> weakref.finalize(obj1, lambda: print('garbage collected!'))
>>> obj1 = obj2 = None
garbage collected!

obj1 = obj2 = None によって、変数 obj1obj2 が参照するそれぞれのオブジェクト A,B への参照がなくなり、参照カウントが 1 減ります。ですがまだ A オブジェクトの b 属性が B オブジェクトを参照しており、また B オブジェクトの a 属性が A オブジェクトを参照しているため、参照カウントは 0 になりません。

結果を見るに、参照カウントを残しながらもオブジェクトはガベージコレクトされました。これは、A オブジェクトと B オブジェクトが循環参照しているため、どちらも到達可能性がないと判断されたためです。

参考

Footnotes

  1. リテラルとは、組み込み型のオブジェクトを生成するための記法です。例えばクォーテーション''やダブルクォーテーション""で任意の unicode 文字を囲むと文字列オブジェクトが生成されます。 また 0 以外から始まる数字を並べると整数型オブジェクトが生成されます。