前回はC言語の構造体とPythonで、Cの構造体相当のことを実現する方法について学びました。今回はそれを発展させて、いよいよオブジェクト指向について取り扱います。

クラスは構造体(データ)と処理(メソッド)のセット

構造体はあるひとまとまりのデータを扱う型のようなものでした。構造体を使うには、まず「どういうデータを持つか」ということを定義して型をつくり、その型にあった「実際のデータ」を作成します。

Pythonでは「クラス」でデータの構造を定義し、実際のデータは「インスタンス」として実現しましたね。前回の例に例えると、クラスがたい焼きの型枠であり、インスタンスはそこから作られるたい焼きにあたります。ちょうど以下の図のとおりです。

クラスとインスタンスの関係

今回はこの話を発展させて、構造体とクラスの違いについて取り扱います。簡単に言ってしまうと、構造体は内部にデータだけを持ちますが、クラスはデータに加えて処理も内部に持っているというのが両者の違いです。どちらも共にまず「定義」をしてから、それを「実体化」するという流れでしたが、クラスはどのようなデータを持つかという定義に加えて、内部にそれらのデータを操作するための特殊な関数を定義することができます。なんでこのようなことをするかはひとまず置いておいて、両者の違いを以下に記します。

構造体とクラスの違い

クラス内で定義された関数は「メソッド」と呼ばれています。この特別な関数にどのような処理をさせても構わないのですが、基本的にはそのインスタンスが持つデータを操作するような処理とすることが多いです。

では、実際にクラスを利用してみます。今回は前回の演習に掲載した生徒の国数英の点数を扱うクラスを発展させます。具体的には__init__内のデータの定義だけでなく、平均点を得るメソッドを追加しています。

class Score:
    def __init__(self):
        self.name = ''
        self.math = 0
        self.english = 0
        self.japanese = 0

    def get_average(self):
        return (self.math + self.english + self.japanese)/3

def __init__(self)は初期化する際に実行される特別な処理だと前回お話ししました。クラスからインスタンスを作成する際に、毎回この処理が呼び出されます。

注目して欲しいのは、def get_average(self)という関数が「クラス内」に定義されていることです。インデントの数から、この関数がクラス内にあることがわかりますね。これがメソッドと呼ばれているものです。

その中身をみると、def __init__の中で定義されたmath、englishといったデータを利用して、その平均値を求めていることがわかります。selfってなんなんだよという話は後ほどしますので、しばし無視してください。

定義ができたので、実際にこのクラスをインスタンス化して利用してみます。まず前回の復習ですが、クラスからインスタンスを作成し、そのインスタンスが持つデータを操作するには以下のようにするのでした。

taro = Score()
taro.name = 'taro'
taro.math = 60
taro.english = 70
taro.japanese = 80

そしてこのインスタンスtaroに対して、メソッドを呼び出します。

ave = taro.get_average()
print(ave)

# 70

見てもらうとわかるように「インスタンス名.メソッド名()」としてメソッドを呼び出していることはわかります。定義は「def get_average(self):」というように引数をひとつ受け取るように見えますが、呼び出す際にはselfに相当する引数を与えていませんね。

以上が簡単なクラスの利用方法でした。

メソッドの引数

さて、先ほど紹介したメソッドの宣言と使い方ですが、ほとんど通常の関数と同じです。違うところといえば、

  • 定義された第一引数は呼び出しに利用されない
  • 呼び出しが インスタンス.メソッド名(引数)

というところでしょうか。引数を持ったメソッドを宣言して利用してみます。

class TestClass:
    def print0(self):
        print('0:')

    def print1(self, a):
        print('1: ' + str(a))

    def print2(self, a, b):
        print('2: ' + str(a) + ' ' + str(b))

instance = TestClass()
instance.print0()
instance.print1('A')
instance.print2('A', 'B')

# 0:
# 1: A
# 2: A B

まず引数が、1、2、3個のprintメソッドをクラス内で定義されていることがわかります。注意してほしいのが、このメソッドを呼び出す側で引数を指定しないと引数1のメソッドが呼び出され、引数をひとつ与えると引数2のメソッドが呼び出されているという点です。定義された第一引数のselfがちょうど無視されているようなイメージですね。

最初はメソッドの引数を間違えることが多いと思うので注意してください。このような動きをする理由は、説明すると長くなるため次回以降に改めて扱います。

手続き型指向 vs オブジェクト指向

クラスやメソッドはどういうことができるものなのか軽く理解してもらえたと思いますが、問題は「なぜこのような使い方をする必要があるか」ということです。文法的にクラスを理解するだけではなく、その背景を理解することがオブジェクト指向を理解するための近道だといえます。

オブジェクト指向と対比するために今まで取り扱った構造体に似た手法で先ほどのコードを書きなおしてみます。

class Score:
    def __init__(self):
        self.name = ''
        self.math = 0
        self.english = 0
        self.japanese = 0

def get_average(score):
    return (score.math + score.english + score.japanese)/3

taro = Score()
taro.name = 'taro'
taro.math = 60
taro.english = 70
taro.japanese = 80

ave = get_average(taro)
print(ave)

先ほど、メソッドとして実装したget_averageを、今度は関数として定義しています。定義する場所がクラスの中ではなくなっていますね。新しい関数get_averageはメソッドと違ってインスタンスを引数として受け取り、「インスタンス.データ」としてmathやenglishの値にアクセスして平均値を求めています。このとき重要なのは以下の点となります。

  • 先の例ではインスタンス内のデータとget_averageメソッドは文法的に密接に結びついている。そのため、クラス設計が正しい限り間違った使い方はされにくい

  • 構造体のような使い方をしている新しい例では、インスタンスと関数 get_averageは、プログラマの「この関数はこの引数を受取る」というルールに基いて利用されている

要するに、クラスを使ったプログラミングではデータ(フィールド、属性)と処理(メソッド)が強く結びついているため、決められた使い方以外をされるリスクが大幅に減ります。一方、構造体にはこのメリットはないため、プログラマが自分たちでルールを作ってデータと処理の結びつきを作る必要があります。

オブジェクト指向のメリットはさまざまありますが、個人的にはこの特性が一番有用なものだと思っています。先ほどお見せした以下の図と今までの例を見比べて、何がどの項目にあたるのかじっくり考えてみてください。

構造体とクラスの違い(再掲)

コンストラクタによるインスタンスの初期化

前回、クラスにデータを持たせる際に以下のように定義をするとお話しました。

class UserInfo:
    def __init__(self):
        self.name = ''
        self.birth = 0
        self.address = ''

実はこの__init__は、「インスタンスの作成時に呼び出される特殊なメソッド」だといえます。一般的にそのような処理をするメソッドは「コンストラクタ」と呼ばれており、形は違えどオブジェクト指向の言語ではなんらかの形で実装されていることが多いです。

試しに__init__内にprint分を加えて、インスタンスを作成してみます。

class UserInfo:
    def __init__(self):
        print('initialize instance')
        self.name = ''
        self.birth = 0
        self.address = ''

taro = UserInfo()
# initialize instance

このプログラムを実行すると、__init__内にあるprint文が呼び出されることがわかります。つまり特別にメソッドとして__init_を呼びださなくても、インスタンスを作成する際に勝手に呼びだされているのですね。

さて、このコンストラクタなのですが見てわかるようにメソッドの宣言に非常に似ています。宣言に引数も含まれていますね。実際、この__init__で定義する引数は、実はクラスをインスタンス化する際に利用することが可能で、そのルールは通常のメソッドと同じく第一引数を飛ばします。

実際の利用例を見たほうがわかりやすいと思うので、上記サンプルを改良して__init__に引数を与えてみます。

class UserInfo:
    def __init__(self, name, birth, address):
        print('initialize instance')
        self.name = name
        self.birth = birth
        self.address = address

taro = UserInfo('taro', 1986, 'tokyo')
print(taro.name)
print(taro.birth)
print(taro.address)

# initialize instance
# taro
# 1986
# tokyo

__init__で定義した引数に対応した形で、インスタンス化が行われていることがわかりますね。具体的には宣言である"def __init__(self, name, birth, address)"と呼び出しである"UserInfo('taro', 1986, 'tokyo')" の対応が取れています。そして__init__内では、与えられた引数をインスタンスのデータの初期化に利用しています。具体的には、今までインスタンス作成後に個別にデータを設定していた処理が不要になり、それと同等のことを__init__の定義内で実行しています。

taro = UserInfo()
taro.name = 'taro'
taro.birth = 1986
taro.address = 'tokyo'

これはコードの行数を減らすという目的もありますが、それよりも「インスタンスの初期化を実施するという保証を与える」という意味合いで非常に大切なことです。たとえば__init__による初期化ではなく、個別にデータを初期化することをしていると、特定のデータの初期化を忘れる可能性が考えられます。

一方、__init__の引数を通して初期化を行うのであれば、初期化を忘れるとそれは「プログラムのエラー」という形ですぐにわかるので、初期化を忘れることは、通常はありません。可能なら__init__を使って初期化するようにしてください。また、__init__は省略可能です。__init__が何も特別なことをしない場合は宣言しないでかまいません。

なお、Pythonの__init__は、厳密にはコンストラクタと異なる別物という見解がありますが、表面上は同様の動きをしてくれるため、上級者以外は気にする必要はないと考えています。もう少しプログラマとしてのレベルがあがって、いろいろな言語を使えるようになってから調べてみると、面白いかもしれませんね。

実装の隠蔽(カプセル化)のメリット

さて、クラスを使うとデータと処理がセットになるということを理解してもらえたと 思いますが、もう少し具体的な例をあげてこの思想を理解してもらうとします。

データと処理がセットになるということは間違った使い方をしにくいということでした。これはインスタンスが持つデータへの操作がメソッドから実行されるため、具体的にどのようなデータをインスタンスが持っているかということを、構造体ほど意識しなくてよいからです。

今まで扱ってきた生徒の成績の例では簡単すぎてメリットがわかりにくいので、もう少し複雑なハードディスクへの書き込み処理を例にしてみます。すでにご存知の方もいるとは思いますが、ハードディスクは大まかに以下のような構造となっています。

ハードディスクの構造 資料:Surachit

おそらく誰しもが見たことがある金属製のケースの中には、プラッタと呼ばれている磁気ディスクが何枚も入っており、その磁気ディスクにアームでデータを読み書きします。各ディスクにはトラックやシリンダーで管理される番地が振られていて、一つひとつの番地の記憶要領は少ないものの「番地A - 番地D」といった形で複数の番地にデータを分散させることで、大きなデータを保存することができています。ただ、ハードディスクの物理番地は、ひとつのファイルであっても連続しているという保証はなく、飛び飛びになっている場合があります。

簡単にハードディスクの構造について話しましたが、ようするに「複雑」であるということがわかっていただけたでしょうか。普通のユーザがハードディスクにデータを書き込む際はこのような内部の詳細について知る必要はなく、あくまでも「ファイルAをロケーションBに書き込む」ということだけができれば十分です。具体的にハードディスクが何枚のプラッタから構成されていて、どの物理番地に何が書かれているかということは、ユーザが知る必要はありません。

このようなシナリオで「実装の隠蔽」は重要な意味を持ちます。先の例でもし実装が隠蔽されていない場合、ハードディスクの構造を理解していないとユーザはデータを保存したり読みだしたりすることができません。そんなの面倒くさくて嫌ですよね(笑)。実装がうまいこと隠蔽されていると、ハードディスクの構造を意識することなくユーザはデータを「簡単」に保存することができます。

たとえば以下のようにハードディスクのクラスは、コンストラクタと読み書きのメソッドのみをユーザに使ってもらえば、ユーザは細かい内部の仕組みを気にせずに読み書き処理が可能です。

実際のところ、この実装の隠蔽はオブジェクト指向だけにある特徴ではなく、Cのような手続き型でもモジュール化やネーミングの規則などによって実現可能です。ただ、何度も説明しているようにオブジェクト指向はデータと処理が適切に結びついているため、手続き型に比べると隠蔽を手伝ってくれる文法がリッチだといえます。つまり、手続き型で隠蔽を実現するのは、オブジェクト指向で隠蔽を実現するよりも難しく、いろいろなプログラミングルールを自分たちで決めないと一気にカオスになってしまいます。

なお、オブジェクト指向であっても間違った設計をすれば実装は隠蔽できません。オブジェクト指向初心者のうちは「誰が何をするべきか」「何を外に見せて、何を隠すか」ということに注意を払って設計することをオススメします。そういうことを繰り返していると自然とオブジェクト指向的な考えが身につくと思います。

ちなみにこのハードディスクの例は説明しやすいように用意したものであり、実際は ファイルシステムが絡んだりしてもっと複雑です。そもそもC言語や、場合によってはアセンブリで作られているはずですしね(笑)。


演習1

以下のクラスにmath、english、japaneseの最高得点の科目名と点数を表示するメソッドを作成してください。

class Score:
    def __init__(self):
        self.name = ''
        self.math = 0
        self.english = 0
        self.japanese = 0

演習2

演習1で作成したクラスでのデータの初期化に、コンストラクタを利用してください。

演習3

手続き型言語に比べて、なぜオブジェクト指向型言語が優れているかを調べてください。


次回は今まで暗黙的に利用していたselfについて扱います。そして値渡しと参照渡しの違いについても扱います。

執筆者紹介

伊藤裕一(ITO Yuichi)

シスコシステムズでの業務と大学での研究活動でコンピュータネットワークに6年関わる。専門はL2/L3 Switching とデータセンター関連技術およびSDN。TACとしてシスコ顧客のテクニカルサポート業務に従事。社内向けのソフトウェア関連のトレーニングおよびデータセンタとSDN関係の外部講演なども行う。

もともと仮想ネットワーク関連技術の研究開発に従事していたこともあり、ネットワークだけでなくプログラミングやLinux関連技術にも精通。Cisco社内外向けのトラブルシューティングツールの開発や、趣味で音声合成処理のアプリケーションやサービスを開発。

Cisco CCIE R&S, Red Hat Certified Engineer, Oracle Java Gold,2009年度 IPA 未踏プロジェクト採択

詳細(英語)はこちら