5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

型パラメータと変位指定について調べたことをつらつら書いてく

Last updated at Posted at 2019-03-23

scalaで型パラメータや変位指定について色々調べていくうちに学んだことや面白いと思ったことをつらつら書き残しておこうと思います。

(*) 私は言語に特別詳しいわけでもないので「こうじゃないかな?」といった憶測を多分に含んでいます。

ちょっとその前に

継承と派生型

ぶっちゃけ継承や派生型を語るには知識が足りなさすぎるのですが、それは置いておいて今回大事になるのは以下のような関係です。

scala> val i: Int = 123
i: Int = 123

scala> val a: Any = i
a: Any = 123

Int型をAny型に代入しています。IntAny(正確にはAnyを継承しているAnyVal)を継承しているので、基本型であるAny型の変数に代入が可能です。これは基底クラスであるAnyの機能を派生クラスであるIntが全て引き継いでいる(というか強制している)ため可能なのだと思われます。つまり、IntAnyとして扱っても問題ないということでしょう。またIntAnyの派生であるということは以下のような包含関係をイメージできるかと思います。

余談 その1

これを機会に継承や派生型をもう一度ちゃんと調べて理解しておこう!と思ったは良いのですが、これらの話の歴史・闇・沼が深すぎてびっくりです。いやー、プログラミング言語って凄いですね。

型パラメータ

では早速型パラメータについてです。githubでライブラリを調べるとしょっちゅう出てきますね。Javaやswiftではジェネリクス(総称型)と呼ばれてますね。ここでは仮にMyContainerを定義していきたいと思います。

class MyContainer[A](val content: A)

val intContainer = new MyContainer(1)
val stringContainer = new MyContainer("string")

どんな型でも入れられるガバガバ状態ですね。ではここでMyContainerで扱うためのBaseContentというクラスと、その派生クラスであるDerivedContentを用意したいと思います。

class BaseContent
class DerivedContent extends BaseContent

何ともまぁ手抜きなクラスですが、こんな手抜きも許してくれるscalaコンパイラに感謝ですね。このBaseContentDerivedContentには継承関係があるので以下のようなイメージになるかと思います。

ではこのDerivedContentを型パラメータとしてとるMyContainer[DerivedContent]MyContainer[BaseContent]の派生型として扱えるでしょうか。答えは「扱えない」です。

val content1 = new DerivedContent()
val container1 = new MyContainer(content1)
val container2: MyContainer[BaseContent] = container1 // compile error

ですが、scalaのコンパイラはとても賢いことに「君のやりたいことはもしかして〜」と提案してくれます。そのエラー内容が以下です。

error: type mismatch;
 found   : this.MyContainer[this.DerivedContent]
 required: this.MyContainer[this.BaseContent]
Note: this.DerivedContent <: this.BaseContent, but class MyContainer is invariant in type A.
You may wish to define A as +A instead. (SLS 4.5)
val container2: MyContainer[BaseContent] = container1
                                           ^
one error found

MyContainerは型Aにおいてinvariantだよ」と教えてくれてます。このinvariantが日本語で言う「不変(または非変)」らしいですね。

変位指定

ではコンパイラに怒られた不変とは何か?ですが、型パラメータの性質の1つみたいですね。型パラメータの性質として以下のようなものがあるそうです。

  • 共変(covariant):型パラメータの派生型(または同一型)を許容。+Aのように定義する。
  • 反変(contravariant):型パラメータの基本型(または同一型)を許容。-Aのように定義する。
  • 不変(invariant):型パラメータの同一型のみ許容。Aのように何も指定せずに定義する。

これまでのMyContainer[A]は不変だったことが分かりますね。ゆえにMyContainer[BaseContent]型へのMyContainer[DerivedContent]型の代入はエラーだったんでしょう。また、上記よりMyContainerの型パラメータAは共変性を持つように+Aという指定をすべきことが分かります。(というかコンパイラもそう言ってますね、本当に賢いやつです。)

余談 その2

「共変 反変」でググると最初にベクトルのお話が出てきます。ゆえに一時期まで私は勝手に「共変と反変はベクトル由来の概念なのかな?」と思っていました(内緒です)。ですがwikipediaによると、これまた圏論由来とありびっくり。さすが圏論、本当どこにでも出てきますね〜。(そもそも圏論の共変と反変自体がベクトルの話由来なのかもしれませんけどね)

共変

では先程のコンパイラ先生の言う通りに変更して共変性なるものを試してみたいと思います。

class MyContainer[+A](val content: A)

class BaseContent
class DerivedContent extends BaseContent

val content1 = new DerivedContent()
val container1 = new MyContainer(content1)
val container2: MyContainer[BaseContent] = container1

コンパイル通りました!これでOreOreListが作れるぜ!

ですがなぜわざわざ+Aなどという変位指定を加えなければならないのでしょうか。「全部共変でいいじゃん」と思う方もいらっしゃるかと思います。私も最初は謎だったのですが、調べていくとJavaの配列が分かりやすい例として挙げられていました。

public class Test {
  public static void main(String args[]){
    String[] strList = {"string"};
    Object[] objList = strList;
    objList[0] = 1;
  }
}

こちらのJavaの簡単な例ですが、コンパイルは通りますが実行時エラーになります。それもそのはず、String[]型の要素にInteger型の値を入れているのですから。これはそもそもJavaの配列がミュータブルにも関わらず共変であるためだそうです。このような事態を防ぐためにも「ミュータブルな型は非変でなければならい」という設計にしなければならないそうです。逆に、イミュータブルであれば(参照のみなので)共変でも大丈夫だそうです。ゆえに、scalaではイミュータブルなListは共変、ミュータブルなArrayは非変となっているそうですね。

scala> val list1 = List(1, 2, 3)
list1: List[Int] = List(1, 2, 3)

scala> val list2: List[Any] = list1
list2: List[Any] = List(1, 2, 3)

scala> val array1 = Array(1, 2, 3)
array1: Array[Int] = Array(1, 2, 3)

scala> val array2: Array[Any] = array1
<console>:12: error: type mismatch;
 found   : Array[Int]
 required: Array[Any]
Note: Int <: Any, but class Array is invariant in type T.
You may wish to investigate a wildcard type such as `_ <: Any`. (SLS 3.2.10)
       val array2: Array[Any] = array1
                                ^

余談 その3

「ミュータブルな型は不変でなければ〜」みたいに書くととてもややこしいですねw 「それはミュータブルなの!?イミュータブルなの!?」とつっこまれそうです。immutableもinvariantも日本語だと「不変」と訳すそうですが、invariantが「非変」とも呼ばれているのはこういった曖昧さを回避するためなんですかね?

あ、あとJavaを悪い例みたいに挙げてしまいましたが、決してJavaをディスりたいわけではないです(念の為)。viva らぶあんどぴーす。

反変

初めて変位指定を知ったとき、正直「共変はまだしも反変は何の役に立つんだ?」と思ってました。そういう時期が、私にもありました。ですが有名な某コップ本にもあるように、関数の定義において知らず知らず使われていたんですね。では実際にscalaの関数の定義を見てみたいと思います。

trait Function1[-T1, +R] extends AnyRef {
  def apply(v1: T1): R
}

実際の定義から少々、いやかなーり勝手に省略した形ですが、引数を1つとる関数の定義です。大事なのはFunction1[-T1, +R]-T1および+Rです。引数の型(-T1)が反変、返り値の型(+R)が共変となっています。これは関数の派生型の一般的なルールとして以下が成り立つためだそうです。以下の x, x', y, y' は型を、x' ⊆ x は x' が x の派生型であることを、x → y は引数の型が x 返り値の型が y の関数を意味しています。(たしか何かの論文からの引用なのですがオレオレメモに式しか残ってない・・・)

x' \subseteq x, y \subseteq y' \Rightarrow x \rightarrow y \subseteq x' \rightarrow y'

まんまFunction1の定義と同じではないでしょうか。以下のように実例を手元で試すのが分かりやすいと思います。

scala> val func1: Any => Int = x => 1
func1: Any => Int = $$Lambda$1108/1312250810@ab2e6d2

scala> val func2: Int => Int = func1
func2: Int => Int = $$Lambda$1108/1312250810@ab2e6d2

scala> val func3: Int => Any = func2
func3: Int => Any = $$Lambda$1108/1312250810@ab2e6d2

(引数をガン無視するので)引数を取る意味が全くないというセンスの欠片もないfunc1ですが、func2およびfunc3に代入できてしまいます。これはfunc1func2(およびfunc3)の派生型であるからです。つまり、func1 ⊆ func2 ⊆ func3であり、以下のようにイメージして良いと思います。

また基本型(func3)を派生型(func1)で置換しても問題ないということから、scalaの関数(の型定義)はリスコフの置換原則を満たしている、ということになるのではないでしょうか(より正確にはscalaを作っている方々がリスコフの置換原則を満たすように関数の型を定義している、とかでしょうか?)。「反変とか何の役に立つんだ?」とか思ってたくせにガッツリお世話になってましたね。(むしろ反変ってこんなにデキるやつだったんですね)

補足

もしかしたら(いやもしかしなくとも)関数の置換部分が分かりにくいかもしれないので、念の為補足しておきます。以降ではfunc1func3のみ考えます。まず引数について見たいと思います。

scala> func3(1)
res2: Any = 1

scala> func3("a")
<console>:13: error: type mismatch;
 found   : String("a")
 required: Int
       func3("a")
             ^

scala> func1(1)
res4: Int = 1

scala> func1("a")
res5: Int = 1

func3の引数の型はIntfunc1の引数の型はAnyです。func1func3として扱う場合、func1にはIntが入ると思われますが、func1の引数の型はAnyであるため問題ありません。次に、返り値も見てみたいと思います。

scala> val a1: Any = func1(2)
a1: Any = 1

scala> val a2: Any = func3(2)
a2: Any = 1

func3の返り値はAnyfunc1の返り値の型はIntです。func1func3として扱っても、Anyを返す想定なのでIntを返しても問題ありません。ここは「ちょっとその前に」の部分と同じような挙動ですね。

あとは視覚的にイメージするとしたら、ざっくりこんな感じになるんではないでしょうか?

余談 その4

なんか反変というよりは関数にフォーカスした感じになってしまった気がします。。。でも反変も関数も調べれば調べるほど「こいつ・・・デキる!」と思えてしまったんで仕方がないと思うんです。

型境界

最後は型境界についてです。型境界に関しましても共変・反変よろしく「上限境界」と「下限境界」があるようですね。

  • 上限境界(upper bounds):T <: Typeのように定義し、型パラメータ(T)はTypeの派生型(または同一型)を許容する。
  • 下限境界(lower bounds):T >: Typeのように定義し、型パラメータ(T)はTypeの基本型(または同一型)を許容する。

上限境界

上限境界の「Typeの派生型(または同一型)を許容する」ということは「少なくともTypeである」と言えるのではないでしょうか。そのため、少なくともTypeの持つメンバーにはアクセスできるはずです。以下はTaskという型を上限境界とした場合の例です。

trait Task {
  def execute()
}

class EchoTask(val message: String) extends Task {
  def execute() = println(message)
}

class Executer[T <: Task](val task: T) {
  def execute() = task.execute()
}

val task = new EchoTask("message")
val executer = new Executer(task)
executer.execute()

また、上限境界では「少なくともXXXを実装している」ということを保証してくれるので、数学ちっくに「Aを満たしているのであればBが成り立つ」といった定理のような実装ができるのが超カッコいいですね。よく上限境界の例として出てくるOrdered[T]とかは「比較可能であればソートが可能」ということを表現しているのではないでしょうか。世の中の天才は凄いですね。

余談 その5

scalaではOrderedの他にOrderingがありますね。こちらは型クラス(implicit parameter)による実装みたいです。

下限境界

では最後に下限境界ですが、これまた反変と一緒で「こいつは一体なんなんだ・・・」って感じでした。しかし共変と反変を学んでいくうちに下限境界の有用性というか大事さが少し分かりました。よく書籍において「共変・反変」の次に「上限境界・下限境界」という順で記載されているのはそのためなんですかね?

話が少し逸れてしまいましたが、下限境界を学ぶためにここではOptionを模倣してMyOptionなるものを実装してみたいと思います。MyOptionの機能としてisEmptygetgetOrElseを実装したいと思います。

abstract class MyOption[+A] {
  def isEmpty: Boolean
  def get: A
  def getOrElse(given: A): A = if (isEmpty) given else get // compile error
}

さっそく怒られました。

error: covariant type A occurs in contravariant position in type A of value given
  def getOrElse(given: A): A = if (isEmpty) given else get
                ^
one error found

「共変な型Aが反変なポジションにあるよ」って感じでしょうか?これを何とかしてくれるのが「下限境界」みたいです。早速getOrElseを修正します。

abstract class MyOption[+A] {
  def isEmpty: Boolean
  def get: A
  def getOrElse[B >: A](given: B): B = if (isEmpty) given else get
}

何とかコンパイルできました。めでたしめでたし・・・ではあるのですが、このままではさっぱりです。「なぜ?」なのかが全く分かりません。ですがこのまま抽象クラスのみでは少々扱い辛いので、Someに相当するMySomeを実装したいと思います。

class MySome[+A](val content: A) extends MyOption[A] {
  def isEmpty: Boolean = false
  def get: A = content
}

次にこのMySomeを使って基本型であるMyOption[Any]の変数にMyOption[Int](正確にはMySome[Int])型の変数を代入したいと思います。

val option1: MyOption[Int] = new MySome(123)
val option2: MyOption[Any] = option1
println(option2.getOrElse("string"))

問題ありませんでした。ですが恐らくではありますが、ここでgetOrElseの型がdef getOrElse(given: A): Aだった場合おかしなことになってるはずです。なぜならMyOption[Int]getOrElseの引数の型はIntであるのに、MyOption[Any]の引数の型がAnyだからです。つまり、Any => Anyであるはずなのに、実際はInt => Intだからです。(必要ないかもですが)視覚的にイメージするとしたら以下のような感じになるかと思います。

コンパイルが通らないのであり得ないですが、仮にできたとしたらclass SomeInt extends MyOption[Int]というクラスを作ってgetOrElseをオーバーライドする際に引数をインクリメントした値を返すという実装をし、上記のようにMyOption[Any]型の変数に代入してgetOrElse("String")を呼び出せば実行時エラーになりそうな気がしますね。

というわけで、共変な型パラメータを用いて関数の引数の型を定義したい場合は、下限境界で型パラメータの基本型を許容する必要があるんですね。

余談 その6

ここではscala標準ライブラリのOptionをガッツリ参考にさせていただきました。githubにあげられてるオープンソースのプログラムはとても勉強になりますね。しかし、こういった良く言えば「参考」悪く言えば「パクり」もっと言えば「謎の改悪」のようなものを意気揚々とネットに公開するのって良いのかな・・・?出典とか引用はなるべくちゃんと書いてるつもりではあるのですが、正直どこまで許されてどこから許されないのかがよく分かっていないです・・・。

感想

調べれば調べるほど面白いですね。そろそろ年齢的にも勉強したことを片っ端から忘れていくかもしれないので備忘録として書き残しましたが、「なぜ」かを考えて書くとどうしても説明口調になってしまいましたね・・・。また、「こうじゃないかな?」という部分も書き残しましたが、実は間違っていたり、副次的なものであったり、はたまた因果が逆転していたりしないか心配です。まぁ、一番心配なのはただのこじつけではないか?ですけどね!もし野生のプロの方で「ここは違うぜ!」とか「本当はこうだ!」とかあった場合は、指摘していただけると幸いです。

参考資料

  • scala標準ライブラリ
    • コミッターの方々には感謝しかありませんね。
  • TOUR OF SCALA
    • 分野毎にシンプルにまとまってたり、サンプルがあったりで非常に助かりました。
  • ドワンゴさんの研修資料
    • 私もこの研修受けたいです。純粋にうらやましいw
  • コップ本
    • 会社で買ったりもしましたが、誰かが読んでるときに読めないのが嫌で自分用に買っちゃいました。

他にもたくさん参考にさせてもらってるんですが、さすがに書ききれないのでお許しください。

5
2
0

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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?