LoginSignup
120
83

More than 1 year has passed since last update.

いろんなプログラミング言語のアドホック多相

Last updated at Posted at 2015-12-13

TL;DR

アドホック多相は型クラスじゃなくても実現できる。
Haskell, Rust, Scalaでは後付けで拡張できるけど、それはアドホック多相の本質ではない。

アドホック多相(Ad hoc polymorphism)

ウィキペディアは辞書ではないのはわかっていますが、それでも
英語版WikipediaのAd hoc polymorphismのページを参考に挙げておきます。

ざっくりまとめると

  • 型階層上は関連性のない複数の型の引数に適用できる、多相的な関数
  • 引数の型に応じて、個別の(アドホックな)実装にディスパッチされる

と、それだけのこと。

「引数の型によって別の実装にディスパッチされるって、それメソッドのオーバーロードと何が違うの」ってことなんですけど、特に違いはありません。同じです。とはいえ、プログラミング言語によって、実現方法や使い勝手は違います。そのあたりのことを、いくつかのプログラミング言語とともに見ていこうかなと思います。

(2018/4追記)どうやら、アドホック多相の特徴のひとつに「定義済みの型に対する関数の実装を後付けできる」というのが含まれると 誤解 している人がいるようです。それは Haskellにおける型クラス、Rustにおけるトレイト、Scalaにおけるimplicitに共通する便利な特徴 ではありますが、アドホック多相そのものの本質とは関係ありません。念のため。

OCaml

言語機能 あり/なし
関数のオーバーロード なし
演算子のオーバーロード なし
オーバーロードを制約とする型引数 なし
型クラス なし
型クラスを制約とする型引数 なし

OCamlにはアドホック多相を実現する言語機能がありません。(たぶんないと思う。ないんじゃないかな。私の知らない例外的な機能とか言語拡張とかの存在は、ちょっと覚悟している。)

標準出力に出力する関数は、引数の型によってprint_intprint_floatprint_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を格納する時には、型引数TBar型なのかBaz型なのかが明確に決まらないとコンパイルできません。

演算子については、組み込み型だけに適用可能な演算子がいくつかあり、オーバーロードされている状態です。たとえば、intの加算にもStringの連結にも、どちらも+演算子を使うことができます。また、doubleの同値比較にもObjectの参照同一性比較にも ==演算子を使うことができます。とはいえこちらも、型が解決されない状態で(アドホックな多相性を保ったまま)自由に利用することはできません。

このように、Javaのオーバーロードにおける多相性にはある種の制約があります。ですが、だからといってアドホックな多相性が全くないということにはなりません。利用する時点でコンパイラが解決しているというだけです。

F#

言語機能 あり/なし
メソッドのオーバーロード あり
演算子のオーバーロード あり
オーバーロードを制約とする型引数 インライン関数のみあり
型クラス なし
型クラスを制約とする型引数 なし

F#処理系は.NETを基盤としていることもあって、オブジェクト指向の機構も備えています。型に対してメソッドを定義できますし、引数の型によるオーバーロードも可能です。演算子は、型に対する特殊なスタティックメソッドとして定義します。こちらもオーバーロード可能です。

それらのオーバーロードされたメソッドや演算子を利用する際は、通常はJavaなどと同様に、利用する時点でコンパイラが型およびオーバーロードを解決してしまいます。解決できない場合はコンパイルエラーになるので、アドホックな多相性を保ったまま自由に利用することはできないように思えます。

ただし、F#にはインライン関数という、コンパイル時にインライン展開される関数を書くことができます。そして、そのインライン関数では、メソッドや演算子のアドホックな多相性を保つことができます。

たとえば、FooBarBaz型が次のように定義されていたとします。

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が定義されていること」という条件を明示的につけています。

このように定義したインライン関数 polyAddpolyFooには、1つ目の引数としてFoo型を渡し、2つ目の引数にBar型またはBaz型を渡すことができます。そしてインライン関数が展開されるときに、初めてオーバーロードが解決されるという動きになります。

Haskell

言語機能 あり/なし
関数のオーバーロード なし
演算子のオーバーロード なし
オーバーロードを制約とする型引数 なし
型クラス あり
型クラスを制約とする型引数 あり

Haskellにはオーバーロードはありません。その代わりに型クラスを使うことで、「引数の型に応じて、個別の(アドホックな)実装にディスパッチされる」というアドホック多相性を実現できます。

数値型に共通な関数や演算子をまとめてシグネチャを規定しているNum型クラスはこんな感じです。

class Num a where
  (+), (*), (-)       :: a -> a -> a 
  negate, abs, signum :: a -> a
  fromInteger         :: Integer -> a

Num型クラスの実例(インスタンス)となる数値型には、演算子 +*-、および関数negateabssignumfromIntegerの実装を用意する必要があります。

ここで出てきている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型のレシーバーです。型パラメータRHSSelfと等しいことを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キーワードが付加された値やオブジェクトを「関連性のない複数の型」に対して用意しておくことで、それら複数の型だけを引数として受け取ることができる、アドホックな多相性を実現できます。

120
83
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
120
83