[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
🔖

ポリモーフィズムが1種類しかないと思ってたエンジニアの備忘録

2025/01/03に公開

はじめに

ソフトウェアエンジニアの福土(@ryoya_cre8or)です。
ふと社内のSlackで「ポリモーフィズムを使っているときに、冗長になるコードをジェネリクスを使うことで綺麗にまとめる事ができる」と呟いたところ、「ジェネリクスもポリモーフィズムの1種だよ」とツッコミをいただき、それを機にポリモーフィズムの概念について整理したいと思っていたので、年末年始にオリャっとまとめちゃいます。

実は共変性・反変性の概念を理解する上でもポリモーフィズムの概念を整理することはすごく良かったので、そこまで記事を書き切りたかったのですが息が足りず...
続きは次回とし本記事ではポリモーフィズムが何なのかについてまとめています。

ポリモーフィズムとは

ポリモーフィズムについてWikipediaの定義を引用すると以下のように書かれています。

ポリモーフィズム(英: polymorphism)とは、それぞれ異なる型に一元アクセスできる共通接点の提供、またはそれぞれ異なる型の多重定義を一括表現できる共通記号の提供を目的にした、型理論またはプログラミング言語理論(英語版)の概念および実装である。この用語は、有機組織および生物の種は様々な形態と段階を持つという生物学の概念からの借用語である。

定義をそのままだと理解するのが難しいのですが、整理するとポリモーフィズムの特徴は以下に当たるようです。

  • 異なる型に一元アクセスできる共通接点の提供
  • 異なる型の多重定義を一括表現できる共通記号の提供

定義にもあるように生物学からの借用語ということで、生物学におけるポリモーフィズムの定義を見ると以下のようでした。

生物において、本来同一であるはずのものが不連続的に異なった形態を示すことを指す。

ここまで踏まえてプログラミング視点でかなり意訳すると「ポリモーフィズムとはさまざまな振る舞いをするクラスや関数を単一のインターフェースで使いまわせるようにすること」だと考えています。

上記の図に対して以下のKotlinコードをご覧ください

animal.kt
interface Animal {
    fun cry()
}

class Dog : Animal {
    override fun cry() {
        println("ワンワン!")
    }
}

class Cat : Animal {
    override fun cry() {
        println("ニャンニャン!")
    }
}

class Bird : Animal {
    override fun cry() {
        println("チュンチュン!")
    }
}

fun makeAnimalCry(animal: Animal) {
    animal.cry()
}

makeAnimalCry関数ではAnimal型のインスタンスをもとに操作を行いますが、その操作は中身がDogだろうがCatだろうがBirdだろうが関係なく一律cryメソッドを呼び出すことができます。

animal.kt
val dog = Dog()
val cat = Cat()
val bird = Bird()

makeAnimalCry(dog)// 出力: ワンワン!
makeAnimalCry(cat)// 出力: ニャンニャン!
makeAnimalCry(bird)// 出力: チュンチュン!

このように異なる型を持つDog,Cat,Birdクラスそれぞれのcryメソッドに対して一元アクセスするための共通のインターフェースであるAnimalにてcryを定義し、インターフェースをそれぞれのクラスに実装したことでインターフェースのcryメソッドから異なる中身を呼び出すことができるようになる、これがポリモーフィズムの特徴です。

そのため、ポリモーフィズムによって異なるクラスや関数を単一的に扱えることで以下のメリットを享受できます。

  • コードの再利用性が高まる
  • コードの柔軟性が上がる
  • コードの可読性が上がる

一般的にポリモーフィズムというと上記の例のようにクラスに対して抽象クラスを継承するかインターフェースを実装することでそのサブタイプとなるクラスの多様性を一元管理できるという例がよく見られますが、実はポリモーフィズムの概念はいくつか存在し、ここではよく使われる3つについて紹介します。
ちなみに上記の例はサブタイピングに属する例となります。

サブタイピング

まず1つ目のサブタイピングとは、ある型が別の型の1種である(is-a関係)という関係を利用しスーパータイプとしてサブタイプのオブジェクトを扱えるようにするポリモーフィズムです。
Wikipediaによるとインクルージョン多相(inclusion polymorphism)とも呼ばれると書かれていました。
スーパータイプは共通のプロパティやメソッドを有しており、サブタイプはそのスーパータイプを継承、もしくは実装することによってより具体的な型を作ることができます。

先ほどの例をそのまま利用するとAnimalがスーパータイプとなり、DogCatがサブタイプとなります。

animal.kt
interface Animal {
    fun cry()
}

class Dog : Animal {
    override fun cry() {
        println("ワンワン!")
    }
}

class Cat : Animal {
    override fun cry() {
        println("ニャンニャン!")
    }
}

サブタイピングを適切に実装することによってリスコフの置換原則を満たし、サブタイプのオブジェクトがスーパータイプのオブジェクトによって置換可能となります

導入の説明でも書きましたが、サブタイピングを用いることで単一のインターフェースから異なる型のサブタイプのオブジェクトを用いることができます。
単一のオブジェクトを通じて異なる振る舞いを書くことができ、不要なif文やswitch文を書かずにコードを書く事ができます

例えばサブタイピングを用いない場合、個別の引数の方から振る舞いを別に定義する必要があり、以下のようなコードになります。

animal.kt
enum class AnimalType {
    DOG, CAT, BIRD
}

class Animal(val type: AnimalType) {
    fun cry() {
        when (type) {
            AnimalType.DOG -> println("ワンワン!")
            AnimalType.CAT -> println("ニャンニャン!")
            AnimalType.BIRD -> println("チュンチュン!")
        }
    }
}

fun makeAnimalCry(animal: Animal) {
    animal.cry()
}

val dog = Animal(AnimalType.DOG)
makeAnimalCry(dog)   // 出力: ワンワン!

また、プログラムを書くときに呼び出し元からセマンティクスを意識してみると、オブジェクトごとの型の違いを意識する必要がない事があります。
そんな時はサブタイピングを活用することで可読性を高めることにも繋がります。

パラメトリック多相

パラメトリック多相とは、型パラメータを使用して、特定の型に依存せずに汎用的な関数やデータ型を定義し、任意の型に対して同一の実装を適用できるポリモーフィズムです。
英語の「parameter(パラメータ)」に由来する「parametric(パラメトリック)」は、型をパラメータとして扱うことを意味しており、この特性を活用することで、コードの再利用性と柔軟性を高めることができます。

例えば以下の例をご覧ください。

print.kt
fun <T> printList(items: List<T>) {
    for (item in items) {
        println(item)
    }
}

val intList: List<Int> = listOf(1, 2, 3)
val stringList: List<String> = listOf("Apple", "Banana", "Cherry")

printList(intList)      // 出力: 1 2 3
printList(stringList)   // 出力: Apple Banana Cherry

この例を見るとわかるように、printListという関数は型パラメータのTを使うことによってIntの型を持つListStringの型を持つListのどちらも引数として受け取ることができます。

データ型でもパラメトリック多相は活用する事が可能です。

stack.kt
class Stack<T> {
    private val elements: MutableList<T> = mutableListOf()

    // スタックに要素を追加する
    fun push(item: T) {
        elements.add(item)
    }

    // スタックから要素を取り出す
    fun pop(): T? {
        if (elements.isEmpty()) {
            println("スタックが空です。")
            return null
        }
        return elements.removeAt(elements.size - 1)
    }

    override fun toString(): String {
        return "Stack(elements=$elements)"
    }
}

上記クラスに対して以下のように呼び出すと同じ型を持つ要素のみスタックに要素を追加できるようになります。

stack.kt
val intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
println(intStack)// 出力: Stack(elements=[1, 2])

data class Person(val name: String, val age: Int)

val personStack = Stack<Person>()
personStack.push(Person("Alice", 29))
personStack.push(Person("Bob", 31))
println(personStack.toString())// 出力: Stack(elements=[Person(name=Alice, age=29), Person(name=Bob, age=31)])

Stackのインスタンスに異なる型の要素を足そうとするとコンパイルエラーとなります。

stack.kt
val intStack = Stack<Int>()
intStack.push('apple')// The character literal does not conform to the expected type Int

このように型パラメータを利用することで型安全性を担保しつつ、コードの再利用性を高め、呼び出し側が柔軟に異なる型を扱うことが可能になります。

アドホック多相

最後に紹介するのがアドホック多相です。
アドホック多相とは、同じ関数名や演算子が異なる型に対して異なる実装を持ち、特定の型ごとに異なる動作を提供するポリモーフィズムです。

アドホック多相は以下の引用先の記事を見ていただくとわかるかと思いますが、先ほどまで紹介していたサブタイピング(inclusion polymorphism)やパラメトリック多相(parametric polymorphism)と別のところに位置します。

https://javapapers.com/core-java/java-polymorphism/#:~:text=is dynamic binding%3F-,Types of Polymorphism,-Polymorphism in computer

アドホックという名前の由来はWikipediaによると以下のとおりです。

The term ad hoc in this context is not intended to be pejorative; it refers simply to the fact that this type of polymorphism is not a fundamental feature of the type system. This is in contrast to parametric polymorphism, in which polymorphic functions are written without mention of any specific type, and can thus apply a single abstract implementation to any number of types in a transparent way.
オーバーローディングや演算子のオーバーローディングとも呼ばれる。
この文脈でのアドホックという用語は侮蔑的な意味ではなく、単にこの種の多相性は型システムの基本的な機能ではないという事実を指している。これはパラメトリック多相性とは対照的で、多相性関数は特定の型に言及することなく記述されるため、透過的な方法で任意の数の型に単一の抽象的な実装を適用することができる。

この記事で言及されているオーバーローディングとは日本語でオーバーロードと呼ばれる仕組みで、同じ名前の関数や演算子をいくつか定義しておくことで、それを呼び出す側の引数の数や型によってそれぞれに適した関数や演算子が呼び出される仕組みです。

https://www.ntt-west.co.jp/business/glossary/words-00735.html#:~:text=オーバーロードとは過,使い分けられる仕組みのこと。

アドホックの話に戻ると、別の記事では以下のような記載がありました。

Universal or parametric polymorphism is another type of polymorphism. Unlike ad hoc, which is based on type, universal polymorphism is type-agnostic. Ad hoc polymorphism is derived from the loose translation of “ad hoc,” which is “for this.” That means the polymorphism relates specifically to certain data types.
ユニバーサル多相(またはパラメトリック多相)は、別の種類のポリモーフィズムです。型に基づくアドホック多相とは異なり、ユニバーサル多相は型に依存しません。アドホック多相という名前は、「アドホック」という言葉の緩やかな翻訳である「このために」に由来しています。これは、このポリモーフィズムが特定のデータ型に特に関連していることを意味しています。

こちらの引用をもとに考えるとアドホック多相とは少なくともパラメトリック多相の対照として導入された概念で、以下2つの特徴を有するポリモーフィズムなのかと推察します。

  • 型システムの基本的な機能にはない
  • 特定の型について個別の実装提供する

型システムは、プログラミング言語においてデータの型を定義し、型の整合性を保証する仕組みです。
コンパイル時や実行時に型の整合性を保証することで実行時にバグを防ぐための仕組みとなっており、サブタイピングやパラメトリック多相を実装する上で実装者はプログラミングの言語仕様に従えば、異なる型の違いを意識せずに単一のインターフェースでコードを記述することができ、再利用性や柔軟性を高める事ができます。
一方のアドホック多相では特定の型ごとに個別の実装を開発者が行う必要があり、言語のシンタックスや機能拡張によって追加的に提供される機能です。

以下にて関数と演算子のオーバーロードによるアドホック多相の例を紹介します。

関数のオーバーロード

関数のオーバーロードを表現したコード例は以下のとおりです。

calculator.kt
// 整数を出力する関数
class Calculator {
    fun add(a: Int): Int {
        return a + 10
    }
    fun add(a: Int, b: Int): Int {
        return a + b
    }
}

val calculator = Calculator()
println(calculator.add(5))// 出力: 整数値: 15
println(calculator.add(5,1))// 出力: 整数値: 6

上記のように同じメソッド名であったとしても引数の個数が違うことで異なるメソッドが呼ばれています。

例えば関数のオーバーロードが提供されていないPHPでは以下のようにコードを書くとaddメソッドを再宣言するなという実行時エラーが出ます。(PHPにもオーバーロードは存在しますがこちらはアドホック多相とは異なります。)

calculator.php
class Calculator {
    public function add($a) {
        return $a + 10;
    }

    // PHP Fatal error:  Cannot redeclare Calculator::add()
    public function add($a, $b) {
        return $a + $b;
    }
}

演算子のオーバーロード

続いて演算子のオーバーロードを見ていきます。
今回紹介するのは算術演算子のオーバーロードを行います。

vector.kt
data class Vector(val x: Double, val y: Double) {
    operator fun plus(v: Vector): Vector = Vector(this.x + v.x, this.y + v.y)
}

val vector1 = Vector(1.0, 1.0)
val vector2 = Vector(2.0, 3.0)

println(vector1 + vector2)// 出力: Vector(x=3.0, y=4.0)

本来、data class同士でプラス演算子を使用することはできません。しかし、plus関数を定義し、operatorキーワードを付けることでプラス演算子をオーバーロードすることが可能になります。これにより、Vectorクラスのインスタンス同士で+演算子を使用した際に、定義したplus関数が呼び出され、異なるVectorインスタンスの加算が実現されます。
これはアドホック多相の一例であり、特定の型(この場合はVector)に対して個別の実装を提供することで、同一の演算子が異なる振る舞いを持つようにしています。

まとめ

今記事にて、巷でポリモーフィズムと呼ばれるものがサブタイピングだけではなくジェネリクスやオーバーロードも含まれるということを記載しました。
単一インターフェースから様々な派生型や振る舞いを呼び出せるというのはコードの冗長性をなくしたり、SLAPを満たすことでコードの保守性を高めることにも繋がります。

普段ライブラリのコードを読むときにしか使わなかった知識を自分でも活用できるようにと具体のコードもいくつか添付しているので、今後のコーディングにて是非活用してみてください!
はじめにで共有したXのアカウントにて技術的な備忘録やプロダクト開発での気づきを呟いているので、ご興味があればお気軽にフォローください!
https://x.com/ryoya_cre8or

株式会社ログラス テックブログ

Discussion