2021

9

19

オブジェクト指向を復習してやんよ!!!(ダックタイピング&継承)

タグ: |

Pocket
LINEで送る
Facebook にシェア
reddit にシェア
LinkedIn にシェア

良書と言われる「オブジェクト指向設計ガイド」からオブジェクト指向の理解を深め中。

今回は5&6章からダックタイピングと継承について

ダックタイピング

もとは個別的・特殊的な事例から一般的・普遍的な規則・法則を見出そうとする帰納法の一つオブジェクト(変数の値)に何ができるかはオブジェクトそのものが決定する。
これによりポリモーフィズム(多態性)を実現することができる。

「もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルに違いない」→
「もしもそのオブジェクトがDuckオブジェクトのようにvoiceメソッドを使えたり、Duckオブジェクトのようにwalkingメソッドを使えたりするのなら、そのオブジェクトはDuckオブジェクトに違いない」的な例。

def animal_ability(animal):
    animal.voice()
    animal.walking()

class Duck():
    def voice(self):
        print("ガーガー")
    def walking(self):
        print("お尻をフリフリ歩きます。")

class Elephant():
    def voice(self):
        print("パオーン")
    def walking(self):
        print("ゆったりと歩きます。")

class Horse():
    def voice(self):
        print("ヒヒーン")
    def walking(self):
        print("パカパカと歩きます。")

duck = Duck()
animal_ability(duck)
# --> ガーガー
# --> お尻をフリフリ歩きます。

elephant = Elephant()
animal_ability(elephant)
# --> パオーン
# --> ゆったりと歩きます。

horse = Horse()
animal_ability(horse)
# --> ヒヒーン
# --> パカパカと歩きます。
  • クラスが別であっても同じ名前のメソッドがあればそれを使用することができ、異なるオブジェクトで同じ操作を切り替えて使えるように書く手法
  • 同じメソッドを持っていれば同様に処理する事が直感的に連想できるのでポリモーフィズムを実現できるとのこと
  • まぁ書き方としては把握したけど、「これってなんとなくだけど危ない使い方じゃない?」という疑念とサジェストに「嫌い」や「間違い」などがあって気になったのでそちらを掘り下げ

ダック・タイピングが嫌悪される理由

何故、こういう背景があるのか?

  • スクリプト言語の多くが値を全てをオブジェクトとして捉えるため、メソッド側からすると、引数が配列でもハッシュでも変わらない処理を書くことができる
def second_element(arr)
  puts arr[1]
end

arr = ["one", "two", "three"]
second_element(arr)
# => two

hash = { 0 => "one", 1 => "two", 2 => "three"}
second_element(hash)
# => two

# メソッドに入るものが配列でもハッシュでも結果(アクセスして返す値)は同じ
  • ただし、実行するまでは呼び出されるメソッドの正誤がわからないので実行までメソッド自体の動作に対する信頼性が曖昧。
  • 動的型付けは簡単に書けるという効率と引き換えに実行時にコンパイラ言語の何十倍・何百倍もの計算量を負担させ、コード内部の詳細な 「プログラム中の約束ごと」を人間側が覚えていることを前提としている
  • 静的型付け→マニュアル車。動的型付け→オートマ車。日常で使う場合は"走る"という動作に対して内部構造を意識する必要は無いが、レースに出る場合は性能を最大限に引き出すための整備が必要
  • 静的型付けの言語では、事前のコンパイルが必須なので、少なくとも車が走り出す際(プログラム実行時)に部品が足りなかった(メソッドが存在しなかった)というエラーが発生する状況はあり得ない
  • 「足す」という動作の値に対して型を宣言し、数字としての「加算」なのか?文字列の「連結」なのかを事前かつ厳密に明言していなければならない
  • 書いてる本人(または関わるチーム)が約束事が守っている(オブジェクトの動作を把握できてる)間は問題にはならないが、根本的に静的型付けでは起こらない現象が動的型付けでは起こってしまう
  • つまり、明示的な型チェックが無いスクリプト言語では、関数が正しく動作する保証が少ない分、特定のユースケースによっては入力タイプを検証する必要が生まれ、ここを楽観 or 悲観視するかの判断基準が生じてしまう事自体がJAVA界隈のエンジニアたちには理解に苦しむらしい

何故、ダックタイピングがダメという主張なのか?

  • 大規模開発では、プログラムの動きによってどんなオブジェクトがメソッドに放り込まれるかはわからないという状況は致命的であり、多くの世界規模サービスがスクリプト言語をメインで採用しない理由でもある
  • 型付けのインタフェースは、「提供する事」に重きを置いているので "できることが明確" だが、ダックタイピングは「自分が動く事」に重きを置いているので "どこまできるが不透明"
  • 使う側が動作を把握しなければならない認知的負荷があり、引き継ぎの際に必要以上の問題に発展する
  • ダック・タイピングでは メソッドやクラスの名前だけを頼りに振る舞いの保証を全振りしている ので、実際に実行してみた時に発覚するエラー の可能性は格段に高まる。
  • 本書にもあった通り、名前のみにロジックを委ねる分、開発者同士で認識の不一致が無いように、事前に如何に備えるかが示されていなければならない(詳細なコメントやエラーを想定したテストの種類など)
# aオブジェクトは引数として名前文字列、住所文字列をとるsetPersonというメソッドを持ち、
# さらには、getAgeという引数無しのメソッドを持ち、整数での年齢を返す。
# bオブジェクトは、仕事を表すが、引数無しのgetTermで日数を表す整数を返し、
# 引数無しのgetSalaryは報酬金額整数を返す。
def sample(a, b)
 ....
end
  • 仕様変更が避けられない大規模開発では、「簡単に」書けることを目指したスクリプト言語ではあるが、結局はその省力化のために失われたものを補完するユニットテストが必須であり、その分の工数が型付け言語よりも多く発生する
  • これなら始めから、ダックタイピングなんて概念の無い、いわゆるコンパイル言語を選んだ方がマシというのが著者の主張。
    なるほど、納得
  • ダック・タイピング自体が静的型vs動的型の宗教戦争のやり玉に上がりやすいテーマ故に嫌悪感を抱く人が目立つ公図だった
  • 参考

ダック・タイピングが健全に機能する範囲を見定める

  • クラスやメソッドの名前だけで振る舞いを把握しきれる個人開発や規模が小さいうちはスクリプト言語の手軽に書ける開発効率の有用性は高く、動かすまでの厳密な検証コードを実装するよりも、ダックタイピングで「ガンガン呼び出せ」「ガンガンぶっこめ」な関数の共通認識による手法のメリットが大きい
  • ただオブジェクト自体よりも、オブジェクト内で定義されたメソッド(動作)に完全に依存する手法なので、乱用しすぎたり、この動作の依存領域や許容する定義が広くなった場合は結びつく範囲も広くなった分、ソースが追いにくくなり、技術負債の温床になる
  • 関数名で機能を推測=人によって推測できる域が異なるので、実装は極力単調な機能であるべき&関数を直接触れる人間をある程度限定するべき
  • 型付けを完全に無視するダックタイピングと、型チェックのために存在するインターフェイスの両立はできないので使い所や場所のガイドラインやルールはなるべく明確にすべき

継承

  • オブジェクト指向を構成する概念の一つで、コードの再利用性や拡張性を高めるための有効
  • 継承される既存の親クラスをスーパークラス、継承した新しく作った子クラスをサブクラスとして扱う

継承関係

  • 「人間は哺乳類の一種」。他に「犬は哺乳類の一種」。よって、人間と犬は哺乳類を継承できる
  • ただし「AはBの一種」といっても、AはBより特化(具体化) される効果を考える必要がある。
    「哺乳類が人間を継承する」という AがBより劣化(抽象化) される効果であってはならない

オーバーライド

  • 親クラスと同じ名前のメソッドを子クラスで新たに定義する
  • RubyのsuperやSuperClass.newと同様にpythonでも super() を使うことで簡単に親クラスが持つ振る舞いをそのまま継承できる
# 哺乳類スーパークラス
class Mammal:
		# インスタンス生成時のコンストラクタ
    def __init__(self, name, age, action):
      self.name = name
      self.age = age
      self.action = action

    # 自己紹介
    def introduce(self):
        return f"名前は{self.name}です。年齢は{self.age}歳です。"
    # 寝る
    def sleep(self):
        return f"{self.name}は、主に{self.action}に寝ます。"

# 人間サブクラス(親クラスのメソッドをsuper()でそのまま継承)
class Human(Mammal):
    def introduce(self):
        print(super().introduce())
    def sleep(self):
        print(super().sleep())

# 犬サブクラス(親クラスをオーバーライド)
class Dog(Mammal):
    def introduce(self):
        about_age = f"{int(self.age) - 1}〜{int(self.age) + 1}"
        print(f"{self.name}は、捨犬なので、年齢はおそらく{about_age}歳です。")
    def sleep(self):
        print(f"{self.name}は、{self.action}に寝たり起きたりします。")

kanomata = Human("山田", "30", "夜")
kanomata.introduce()    #--> 名前は山田は、年齢は30歳です。
kanomata.sleep()    #--> 山田は、主に夜に寝ます。

pochi = Dog("ポチ", "3", "好きな時間")
pochi.introduce()  #--> ポチは、捨犬なので、年齢はおそらく2〜4歳です。
pochi.sleep()      #--> ポチは、好きな時間に寝たり起きたりします。

privateとpublic

  • 変数や関数名の前に__(アンダースコア2つ)を付けることでプライベート属性への設定ができる
  • プライベート属性を持った関数や変数に対してクラスの外から直接アクセスすることはできなくなる
  • プライベート属性へはサブクラスを経由することで保護フィールドの役割を持たせる事が可能
class Hobby:
    def __init__(self):
	    self.attr = "映画鑑賞"  # パブリック変数
	    self.__attr = "AV鑑賞"  # プライベート変数

    def fake(self):
      print(f"趣味は{self.attr}です。")

    def __secret(self):
      print(f"趣味は{self.__attr}です。")

    def truth(self):
      self.__secret()

hobby = Hobby()
# publicなメソッドなので呼び出しOK
hobby.fake()   #---> 趣味は映画鑑賞です。

# サブクラス内で呼び出されるのでOK
hobby.truth()   # -->趣味はAV鑑賞です。

# Classの外側から直接の呼び出し
hobby.__secret()   # private設定のメソッドなのでエラー

抽象クラス

  • 「食べる」や「寝る」など共通の機能を取り出しインターフェースを複数実装できる具象クラスとは異なり、抽象クラスは1つしか継承することができない
  • インターフェースは実装を持てないが、抽象クラスは実装を持つことができる
    • インターフェース: オブジェクトに機能を与えるもの
    • 抽象クラス: 複数の概念から抽象化されたオブジェクト
  • わかりやすかった具体例(トヨタのプリウスによる図解)
    • プリウスという自動車は厳密には存在せず、世代によって型式やパーツの規格や内部構造は全く異なり、各マイナーチェンジの兄弟それらの型式が抽象クラスPriusを継承している各世代の「プリウス」となっている
    • 「環境に配慮したハイブリッドカー」というコンセプトを採用した車種の具現化=「プリウス」とされているためサブクラス同士が共通の機能を持つわけではなく、その抽象クラスの概念に共通点のあるサブクラスと関係を持つ
    • 具象クラスとの継承関係は「hybrid is a prius」(ハイブリッドであることがプリウス)の関係

インターフェースとの違い

  • インターフェースはオブジェクトに機能を与えるもの
  • オブジェクトとして異なるもの同士でも同じ機能を有している場合は同じインターフェースのインスタンスとして扱うことができる
  • インターフェイスと実装クラスの「実装」の関係は外から見える何かと何かをくっつける窓口
  • 「register can bill credit card」(レジはクレジットカード会計が出来る)の関係
  • ここにさらに「現金で会計」や「QRで会計」などインターフェースを経由して振る舞いが成立する機能を実装することで拡張していく

まとめ

  • 本書では「ダック・タイピングも積極的に取り入れよう」的な表現だったが、あくまで変更にかかるコストを削減するためのひとつの手段
  • デメリットもちゃんと理解した上で、運用上「ここはダック・タイピングレベルの実装で充分」というオブジェクトの検討と共有が環境で上手くコントロールできれば良いのかな?という印象
  • 抽象クラスについては具体的なコードに起こす時間がなかったので、ちゃんとコードでも理解していきたい
Pocket
LINEで送る
Facebook にシェア
reddit にシェア
LinkedIn にシェア

トップへ