TL;DR
アドホック多相は型クラスじゃなくても実現できる。
Haskell, Rust, Scalaでは後付けで拡張できるけど、それはアドホック多相の本質ではない。
アドホック多相(Ad hoc polymorphism)
ウィキペディアは辞書ではないのはわかっていますが、それでも
英語版WikipediaのAd hoc polymorphismのページを参考に挙げておきます。
ざっくりまとめると
- 型階層上は関連性のない複数の型の引数に適用できる、多相的な関数
- 引数の型に応じて、個別の(アドホックな)実装にディスパッチされる
と、それだけのこと。
「引数の型によって別の実装にディスパッチされるって、それメソッドのオーバーロードと何が違うの」ってことなんですけど、特に違いはありません。同じです。とはいえ、プログラミング言語によって、実現方法や使い勝手は違います。そのあたりのことを、いくつかのプログラミング言語とともに見ていこうかなと思います。
(2018/4追記)どうやら、アドホック多相の特徴のひとつに「定義済みの型に対する関数の実装を後付けできる」というのが含まれると 誤解 している人がいるようです。それは Haskellにおける型クラス、Rustにおけるトレイト、Scalaにおけるimplicitに共通する便利な特徴 ではありますが、アドホック多相そのものの本質とは関係ありません。念のため。
OCaml
言語機能 | あり/なし |
---|---|
関数のオーバーロード | なし |
演算子のオーバーロード | なし |
オーバーロードを制約とする型引数 | なし |
型クラス | なし |
型クラスを制約とする型引数 | なし |
OCamlにはアドホック多相を実現する言語機能がありません。(たぶんないと思う。ないんじゃないかな。私の知らない例外的な機能とか言語拡張とかの存在は、ちょっと覚悟している。)
標準出力に出力する関数は、引数の型によってprint_int
やprint_float
やprint_string
などを使い分ける必要があります。
演算子も、int
の加算には+
演算子を使いますが、float
の加算は+.
演算子、string
の連結は^
演算子です。
その代わり、型推論が大変強力です。ほとんどのケースで、引数や戻り値などの型を明示しなくても適切に推論されます。(たぶんされると思う。されるんじゃないかな。レアケースの存在は、ちょっと覚悟している。)
Java
言語機能 | あり/なし |
---|---|
メソッドのオーバーロード | あり |
演算子のオーバーロード | 組み込み型だけあり |
オーバーロードを制約とする型引数 | なし |
型クラス | なし |
型クラスを制約とする型引数 | なし |
Javaではメソッドのオーバーロードは可能です。オブジェクト(スタティックメソッドの場合はクラス)の型とメソッド名と引数の型の組み合わせでメソッドのシグネチャが静的に決まります。そのため、ひとつのクラスに、名前は同じだけど引数の異なるメソッドを複数定義できます。
ですが、オーバーロードが解決されない状態で(アドホックな多相性を保ったまま)、プログラムのあちこちから自由に利用することはできません。たとえば、Foo
クラスのインスタンスメソッドとして、void foo(Bar bar)
とvoid foo(Baz baz)
の2つがオーバーロード定義されていたとします。その時に、型引数T
を用いるジェネリックなメソッドvoid <T> hoge(Foo f, T t)
の中からf.foo(t)
という呼び出しをすることはできません。また、関数型インターフェース Consumer<T>
型の変数にメソッド参照foo::Foo
を格納する時には、型引数T
がBar
型なのかBaz
型なのかが明確に決まらないとコンパイルできません。
演算子については、組み込み型だけに適用可能な演算子がいくつかあり、オーバーロードされている状態です。たとえば、int
の加算にもString
の連結にも、どちらも+
演算子を使うことができます。また、double
の同値比較にもObject
の参照同一性比較にも ==
演算子を使うことができます。とはいえこちらも、型が解決されない状態で(アドホックな多相性を保ったまま)自由に利用することはできません。
このように、Javaのオーバーロードにおける多相性にはある種の制約があります。ですが、だからといってアドホックな多相性が全くないということにはなりません。利用する時点でコンパイラが解決しているというだけです。
F#
言語機能 | あり/なし |
---|---|
メソッドのオーバーロード | あり |
演算子のオーバーロード | あり |
オーバーロードを制約とする型引数 | インライン関数のみあり |
型クラス | なし |
型クラスを制約とする型引数 | なし |
F#処理系は.NETを基盤としていることもあって、オブジェクト指向の機構も備えています。型に対してメソッドを定義できますし、引数の型によるオーバーロードも可能です。演算子は、型に対する特殊なスタティックメソッドとして定義します。こちらもオーバーロード可能です。
それらのオーバーロードされたメソッドや演算子を利用する際は、通常はJavaなどと同様に、利用する時点でコンパイラが型およびオーバーロードを解決してしまいます。解決できない場合はコンパイルエラーになるので、アドホックな多相性を保ったまま自由に利用することはできないように思えます。
ただし、F#にはインライン関数という、コンパイル時にインライン展開される関数を書くことができます。そして、そのインライン関数では、メソッドや演算子のアドホックな多相性を保つことができます。
たとえば、Foo
、Bar
、Baz
型が次のように定義されていたとします。
type Bar = Bar
type Baz = Baz
type Foo = Foo with
static member (+) (_: Foo, _: Bar) = "Foo + Bar"
static member (+) (_: Foo, _: Baz) = "Foo + Baz"
member this.foo(_: Bar) = "Foo#foo Bar"
member this.foo(_: Baz) = "Foo#foo Baz"
Foo
型には+
演算子がオーバーロード定義されていて、左辺にFoo
型、右辺にはBar
またはBaz
型を取ることができます。また、Foo
型にはfoo
インスタンスメソッドがオーバーロード定義されていて、引数にはBar
型またはBaz
型を取ることができます。
このような状態で、+
演算子を利用するインライン関数を定義してみます。
> let inline polyAdd (f: Foo) x = f + x;;
val inline polyAdd :
f:Foo -> x: ^a -> ^b
when (Foo or ^a) : (static member ( + ) : Foo * ^a -> ^b)
polyAdd
インライン関数は引数を2つ取ります。引数f
の型はFoo
ですが、引数x
の型は指定していません。関数の本体はf + x
のように+
演算子を呼び出しています。結果として、インライン関数polyAdd
の引数x
の型は、「任意の^a
型、ただしFoo
型か^a
型に演算子+
がオーバーロード定義されていることが条件」と推論されました。
もうひとつ、foo
メソッドを利用するインライン関数を定義してみます。
> let inline polyFoo (f: ^a) (x: ^b) =
- (^a : (member foo: ^b -> string) (f, x));;
val inline polyFoo :
f: ^a -> x: ^b -> string when ^a : (member foo : ^a * ^b -> string)
polyAdd
インライン関数は引数を2つ取ります。今度は、引数f
の型をFoo
型とすることはできませんが、代わりに「任意の^a
型、ただし1つの引数を取ってstring
を返すインスタンスメソッド foo
が定義されていること」という条件を明示的につけています。
このように定義したインライン関数 polyAdd
や polyFoo
には、1つ目の引数としてFoo
型を渡し、2つ目の引数にBar
型またはBaz
型を渡すことができます。そしてインライン関数が展開されるときに、初めてオーバーロードが解決されるという動きになります。
#Haskell
言語機能 | あり/なし |
---|---|
関数のオーバーロード | なし |
演算子のオーバーロード | なし |
オーバーロードを制約とする型引数 | なし |
型クラス | あり |
型クラスを制約とする型引数 | あり |
Haskellにはオーバーロードはありません。その代わりに型クラスを使うことで、「引数の型に応じて、個別の(アドホックな)実装にディスパッチされる」というアドホック多相性を実現できます。
数値型に共通な関数や演算子をまとめてシグネチャを規定しているNum
型クラスはこんな感じです。
class Num a where
(+), (*), (-) :: a -> a -> a
negate, abs, signum :: a -> a
fromInteger :: Integer -> a
Num
型クラスの実例(インスタンス)となる数値型には、演算子 +
、*
、-
、および関数negate
、abs
、signum
、fromInteger
の実装を用意する必要があります。
ここで出てきているa
は「Num
型クラスの実例(インスタンス)である任意の型」を表しています。無理やりJavaのジェネリクスっぽく書くなら、<T instantiates Num>
という制約がついている型引数T
みたいなもの、と思ってください。
で、型クラスを利用する関数はこんな感じです。
Prelude> let negateDouble x = 2 * negate x
Prelude> :t negateDouble
negateDouble :: Num a => a -> a
Prelude>
GHCiで、符号反転して2倍するnegateDouble
関数を定義してみたところ、型が自動的に推論され、「Num
型クラスの実例(インスタンス)である任意の型a
について、型a
を引数にとって型a
を返す関数」となりました。
このnegateDouble
関数に、具体的な型が定まった値、たとえばInt
型の値を渡した場合は、Int
型について実装されたnegate
や*
が呼び出されます。
Haskellにおいてある型が型クラスを実装するかどうかはアドホックに決められます。結果として、アドホックな多相性が型クラスによって実現できます。(というか、Haskellには型と型とに階層関係を持たせることはできないです。多分できないと思う。できないんじゃないかな。)
#Rust
言語機能 | あり/なし |
---|---|
メソッドのオーバーロード | トレイトで実現 |
演算子のオーバーロード | トレイトで実現 |
オーバーロードを制約とする型引数 | トレイトで実現 |
型クラス | トレイトで実現 |
型クラスを制約とする型引数 | トレイトで実現 |
Rustの型システムの面白いところはトレイトの表現力が豊かなところにあると思います。トレイトに定義されたメソッドは、基本的には、receiver.foo(args)
の形で呼び出しますが、前置単項演算子と中置二項演算子も定義できます。
ひとつのトレイトにメソッドのオーバーロード定義をすることはできませんが、別のトレイトで同じ名前のメソッドを定義して、ある型が両方のトレイトを実装するようにすれば、ひとつの型におけるメソッドオーバーロードは実現できます。
演算子はトレイトに定義されています。Rustの演算子はあらかじめ定められた種類しかなく、独自の記号列を演算子として使うことはできません。ですが、演算子が定義されたトレイトを実装することで、型独自の演算をさせることができます。演算子が定義されたトレイトはstd::ops
モジュールにあります(演算子ひとつにつきひとつのトレイトが用意されています)。サンプルとして、加算演算を表すstd::ops::Add
を見てみます。
pub trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
大文字始まりのSelf
キーワードは、このトレイトを実装している型で、小文字始まりのself
は、Self
型のレシーバーです。型パラメータRHS
はSelf
と等しいことを1行目で表していますので、加算演算の左項self
と右項rhs
は同じ型となります(←コメントで指摘いただきましたが、これはRHS
の型を指定しなかった場合の挙動でした)。加算結果の型は、Self
と同じ型になることが普通ですが、演算によっては別の型となることもあり得ます。そのような場合は、戻り値の型はOutput
で指定することができるようになっています。
Rustの型が他の型を継承することはできませんが、あるトレイトが他のトレイトを多重継承することはできます。また、トレイトにはメソッドの定義(シグネチャ)だけでなくデフォルト実装を含めることもできます。
Rustにおいてある型がトレイトを実装するかどうかはアドホックに(型階層とは無関係に)決められます。結果として、アドホックな多相性がトレイトによって実現できます。ただし、実装は型かトレイトの定義があるクレートに書かないといけないようです。
(補足:RustのトレイトはHaskellの型クラスと同等の使い方ができます。汎用的なMonadトレイトをどう実現するか、といったことは議論されているみたいですが、それはこの記事の範囲ではないので説明しません)。
#Scala
言語機能 | あり/なし |
---|---|
メソッドのオーバーロード | あり |
演算子のオーバーロード | メソッドで実現 |
オーバーロードを制約とする型引数 | なし |
型クラス | implicit valueで実現 |
型クラスを制約とする型引数 | implicit parameterで実現 |
Scalaのメソッドオーバーロードは基本的にはJavaと同じです。違いがあるとすれば、メソッドを実装するのはクラス以外にもトレイトやシングルトンオブジェクトで可能という程度です。
Scalaの演算子の実体はメソッドです。メソッドの引数が1個の時は、メソッド呼び出しの.
と()
を省略して、中置二項演算子として書くことができます(二項演算の左項と右項の順番を入れ替える場合や、前置単項演算子の場合など、特別なルールがいくつかあります)。Scalaではメソッド名(識別子)に記号を使うこともできるので、+
メソッドを+
演算子のように使うことができるという仕掛けです。
Scalaのジェネリクスでは、型引数の制約として、「型T
が、オーバーロード定義されたメソッドFoo#bar
の引数の型と一致する」であるとか、「型T
に、型T
の引数をとって型T
の戻り値を返すメソッド+
が定義されている」といったことを指定することはできません。後者については、メソッド+
を定義するトレイトの制約として表現することはできますが、トレイトも型である以上、「型階層上の関連がない」というアドホック性があるとは言えないでしょう。トレイトと関係なくメソッド+
を実装している型は適合しませんし。
一方、Scalaにはimplicit value/implicit parameterという機能があります。たとえば、型T
に関連する算術演算をトレイトNum[T]
に定義したとします。
trait Num[T] {
def add(lhs: T, rhs: T): T
def sub(lhs: T, rhs: T): T
def mul(lhs: T, rhs: T): T
def div(lhs: T, rhs: T): T
}
このトレイトで定義した算術演算を利用したい関数があったとします。しかし、型T
がこのトレイトNum[T]
をミックスインしているとは限らないとします。そんな時、その関数はトレイトNum[T]
の実装をimplicit parameterとして暗黙に受け取る宣言をします。
def someFunction[T](x: T, y: T)(implicit op: Num[T]) = {
val z = op.add(x, y) // opのメソッドを使ってT型の算術演算をする
...
}
コンパイラは、someFunction[T]
関数を呼び出すコードをコンパイルするときに、implicit
キーワードが付加された、型が適合する値やオブジェクトを自動的に探し、見つかったものをimplicit parameterとして渡します。見つからない場合はコンパイルエラーになります。
implicit object IntOperations extends Num[Int] {
def add(lhs: Int, rhs: Int): Int = lhs + rhs
def sub(lhs: Int, rhs: Int): Int = lhs - rhs
def mul(lhs: Int, rhs: Int): Int = lhs * rhs
def div(lhs: Int, rhs: Int): Int = lhs / rhs
}
def otherFunction() {
someFunction(1, 2) // IntOperationsのインスタンスが暗黙的に渡される
...
}
よって、implicit
キーワードが付加された値やオブジェクトを「関連性のない複数の型」に対して用意しておくことで、それら複数の型だけを引数として受け取ることができる、アドホックな多相性を実現できます。