3/20/2021

20年代のオブジェクト指向言語

アダム・ネルソンのブログより

オブジェクト指向プログラミングは、今やすっかり廃れてしまいましたが、しばらく前からそうなっています。新しいプログラミング言語が意図的にオブジェクト指向(OO)を採用することはほとんどありません。そして、これには正当な理由があります。OOは多くの場合、多くの定型文を必要とし、コードを不自然なオブジェクト階層に押し込め、隠れたミュータブル状態を助長します。

しかし、もし2021年にJavaやC#のような静的型付けされた新しいOO言語をゼロから作ったとしたら、関数型プログラミングから学んだ全てのことと、10年以上にわたる厳しいOO批判を受けて、この問題を解決することができるでしょうか? 特に、レガシーコードとの互換性を期待していない場合はどうでしょうか?

まず、新しいOO言語が持つべき、いくつかの明白な譲れないものがあります。

  • null安全
  • 安全なキャスト
  • オプショナル名前付き引数
  • ジェネリクス
  • デフォルトでイミュータブル

これらのテーマについてはすでに十分語られているので、今日はあまり説明する必要はないでしょう。

ここでは、私が個人的に新しいOO言語に望む、あまり目立たない選択肢をいくつか紹介します。

  • クラスに基づく発見性
  • 多重継承
  • ミニマル構文
  • 高階型
  • 例外がない
  • 統合されたクラスと型クラス
  • 破壊せずにパターンマッチング

これら共通しているのは、言語を純粋にOO(「マルチパラダイム」ではない)に保つことですが、関数的な機能を型システムに統合し、OO的な方法で実装することです。

クラスに基づく発見性

Scalaはここしばらくの間の、私のお気に入りのプログラミング言語です。OOと関数型コンセプトの理想的な結合にかなり近いものがあります。しかし、Scalaの最大の問題は、Javaクラス、代数的データ型、独立した関数、暗黙の了解など、あらゆることを行うためのたくさんの方法があることです。

ほとんどすべてのJVM代替言語と同様に、Scalaは名詞の王国(日本語訳)を脱出し、すべてのものがクラスに属するというJavaの要件に反しています。実際、Scalaは、実用性と関数型プログラミングの名の下に、いくつかのOOの正統とされる考えを無視しています。

しかし、私たちは名詞の王国に対して不公平だったかも知れません。Javaの文法は動詞を禁止しているわけではなく、常に名詞が先に来るような一貫した語順を強制しているだけです。これには、IDEやドキュメントの発見性という大きな利点があります。

すべてがクラスのメンバーである場合、すべての非ローカルなメソッド呼び出しはドットの後に来るので、すべての非ローカルなメソッド呼び出しをドロップダウンで自動補完することができます。Java APIは、ドロップダウンとポップアップ・ドキュメントのみを使用して、IDEで100%探索できます。C#のように、既存の型に対する新しい操作を処理するための拡張メソッドを提供できるため、ユーティリティ関数がフリー関数やユーティリティクラスの静的メソッドとして浮いてしまうことはありません。

理想的には、この言語はトップレベルの宣言がclass、extension、aliasの3種類だけです。

多重継承

継承には悪い評判がつきものです。確かに、それには理由があります。スーパークラスを拡張する場合、ほとんどの場合、本当に必要なのはインタフェースがある種のコンポジションです。

しかし、すべてのものがオブジェクトであり、すべての関数がオブジェクトに属していると決めてしまうとmixin、トレイト、デフォルト・メソッドを使用したインタフェース、プロキシ・メソッドを使用した構成など、すべてのコードの再利用が継承のように見えてきます。そして、OO言語が新しい機能を開発すると、それらは、名目を除いて、多重継承になる傾向があります。なぜそれを受け入れないのでしょうか?

継承の問題は、(間違いなく)メンタルモデルの問題であり、言語機能の問題ではありません。インタフェースはクラスのサブセットです。型システムでは記述できない不変性を実現するために、インタフェースを実装を結びつけることが必要な場合があります。 Is-a関係は便利ですが(コレクションライブラリを参照)、その線引きはしばしば間違った場所に引かれます。脆弱な基底クラスは、意識的に避けるべきアンチパターンであり、型クラスまたはインタフェースのデフォルトメソッドでも同じ間違いを犯す可能性があります。

そして、多重継承は、Javaのクラス階層に見られるような、考え過ぎや自転車置き場(bikeshedding)の例えをほとんどを解決してくれます。多重継承が正常に行われている言語では、継承は合成(composition)のように見えます。必要な機能を選んで、それをマッシュアップするだけです。階層はツリーではなく、DAGです。多重継承が実現すれば、インタフェースと抽象クラスの区別がなくなるため、インタフェースは必要ありません。

では、名前の衝突をどのように解決しますか? メソッド名とフィールド名を完全修飾にすればいいのです。FooがBarとBazを継承していて、どちらもメソッドquxを定義している場合、foo.Bar\quxやfoo.Baz\quxでインスタンスfooのどちらかのメソッドを参照することができます。ここでは名前空間の区切り文字として、バックスラッシュを使用していますが、これは/に似ていますが、除算記号のようなものとして使われていないためです。

安全なキャストは、名前の衝突を解決することもできます。BarとBazがともにQuxを継承していて、あるメソッドがQuxの引数を期待する場合、fooはfoo as Barまたはfoo as Bazのいずれかとして渡すことができます。

これがうまく機能しない場所は、悪名高い菱形継承問題です。

class A {
  foo() { print("A") }
}

class B extends A {
  foo() { print("B") }
}

class C extends A {
  foo() { print("C") }
}

class D extends B & C

静的型Aのオブジェクトがあるとします。それはDのインスタンスですが、コンパイラはそれを認識していません。foo()と呼ぶとどうなるのでしょうか? BやCのインスタンスであることが分からないので、コンパイル時に曖昧さを解消することができません。

最も簡単な方法は、Kotlinを参考にして、Dがfooと曖昧さをオーバーライドすることを要求することです。

ミニマル構文

Scalaは、sealed、case class、objectという予約語を使ってよくあるパターンを自動化しています。これらのキーワードがなくても、Javaと同じようにシングルトン・オブジェクトやsum型を作ることができますが、コンパイラは何をしているのか知ることができません。なぜ、このような二級市民的な型を作ることができるのでしょうか?

これが、私の実験的なScala-but-better構文による、OOの代数的データ型です。

class Address {
  new(
    this.state: String,
    this.city: String,
    this.street: String,
    this.zip: Int
  )
}

class List(T) {
  private new()

  head: Option(T)
  tail: Option(List(T))

  static class Nil extends List(Nothing) {
    static self = new()

    head = None.self
    tail = None.self
  }

  static(T) class Cons extends List(T) {
    private(List) new(private this.car: T, private this.cdr: List(T))

    head = Some.new(car)
    tail = Some.new(cdr)
  }

  static nil = Nil.self
  static(T) alias cons = Cons\new
}

この例では、product型、sum型、2つの抽象フィールドを持つ抽象クラス、およびシングルトン・オブジェクトが含まれています。しかし、abstract、sealed、case class、objectといったワードを使用していません。ボディのないフィールドやメソッドは抽象的です。抽象メンバーや保護されたコンストラクタを持つクラスは抽象です。プライベート・コンストラクタを持つクラスと、プライベート・コンストラクタを持つ静的な内部サブクラスは、sealed sum型です。static selfプロパティ(これは魔法のキーワードで、完全にエスケープすることはできません)を持つクラスはシングルトンであり、プライベートコンストラクタを意味します。

コンパイラは、これらのパターンが使われているのを見れば、それを知るべきです。追加のキーワードは必要ありません。エラーメッセージは、ユーザが誤ってクラスを抽象化してしまった場合や、クラスを完全には封印しなかった場所に指摘するのに十分な情報量があるべきです。

構文についての注意点です。

  • コンストラクターの名前はnewです。オブジェクトはClassName.new(...)で構築されます。
  • this.で始まるコンストラクタのパラメータは、Scalaコンストラクタ・パラメータのように、接頭辞にvalが付きます。
  • 大文字は重要で、Haskellでの意味と同じです。大文字で始まる場合、それは型です。
  • ジェネリックスは、<>や[]の代わりに括弧を使用します。クラスは大文字で区別されるため、これは明確です。ジェネリックのメソッドは、Scalaと違ってカリー化がないため、2つの括弧を使うことができます。
  • ジェネリックスはreifiedされます(Javaとは異なり、C#のように)。そのため、staticには型パラメータが必要な場合があり、Listに対するstaticなのか、List(T)に対するstaticなのかが分かります。List(A).NilとList(B).Nilは同じクラスですがList(A).Cons、とList(B).Consは異なります。

高階型(Higher-kinded type)

これらは、T(_)のように、独自の型パラメータをとる型パラメータです。私が知っている、OOクラスとHKTの両方をサポートする唯一の言語はScalaだけですが、これは奇妙なケースです。Scalaは、側面にMLがボルトで固定されたJavaです。もし、Scalaで関数的なことをしたいのなら、オブジェクトの代わりに型クラスを使った、別の関数的な世界に行き着くことになります。なぜ、Monadは拡張できるクラスになれないのでしょうか?

私が思うに、それは次のようなものです。

class Functor(F(_), A) where This extends F(A) {
  map(B)(fn: (A) {B}): F(B)
}

class Monad(F(_), A) extends Functor(F, A) where This extends F(A) {
  static(F, A) unit(value: A): F(A)

  flatMap(B)(fn: (A) {F(B)}): F(B)

  map(B)(fn: (A) {B}): F(B) = flatMap((it) { unit(fn.call(it)) })
}

その他の構文上の注意:

  • 関数型は(Args) {Return}と記述され、これはラムダ構文(args) {body}を反映している。
  • 静的メンバーは抽象的でき、オーバーライドも可能です。型パラメータで静的メソッドを呼び出すことができる。

ここでの本当のトリックは、where This extends F(A)です。このwhere節はCeylonのgiven節から借用したもので、型パラメータの制約を定義しています。Thisは、継承しても常に現在の型を参照する魔法の型です。クラスTがMonadを継承した場合、制約であるwhere This extends F(A)は、where T extends F(A)になります。

つまり、リストモナドを定義するには、class List(A) extends Monad(List, A)と記述します。Eitherモナドはclass Either(A, B) extends Monad(Either(_, B), A)になります。Monadの第一引数は単なる定型文ではなく、重要な意味を持つことに注意して下さい。

これは本物のモナド型です。しかし、これは完全なOO型でもあります。つまり、自由関数も代数的データ型も、型クラスもありません。

例外がない

最新の言語では、エラー処理への代替アプローチが一般的です。私は、新しいOO言語でも、型付けされ、階層的に整理されたエラーオブジェクトを分類すべきだと思います。ただし、例外のスローイング(throwing)とスタックの巻き戻し(unwinding)は不要です。

GoとRustは、エラー処理に対する2つの代替アプローチを提供しています。どちらもエラーを値として扱いますが、Goは複数の戻り値を使ってエラーの状態を返しますが、RustはResultモナドを使います。この言語ではMonadをクラスとして簡単に定義できることが既に確立されているので、エラーモナドは理想的なものと思われ、Rustのtry!のような構文サポートも可能でしょう。

統合されたクラスと型クラス

クラスと型クラスは非常によく似ています。ほとんどの言語にはどちらか一方がありますが、両方はありません。Scalaにはその両方がありますが、型クラスは一種の応急措置(kludge)であり、通常のクラスやインタフェースとは完全に別物となっています。

このMonadの例を見ると、いくつかの機能を追加することで、クラスは関数型言語で型クラスが既に行っていることのほとんどを行うことができます。

しかし、拡張メソッドや型クラスのようなスタンドアロンインスタンスを追加することもでき、RustやHaskellのような型代数を作ることができます。

// Add a sum method to Lists of Ints
extension Sum of List(Int) {
  sum(): Number = reduce((a, b) {a + b}, 0)
}

// Add a Comparable instance to Lists of Comparables
extension ComparableList(A extends Comparable(A)) of List(A) as Comparable(List(A)) {
  compare(that: List(A)): Comparison = // ... implementation omitted
}

extensionは、既存の型に新しいメソッド、場合によっては新しいスーパークラスを追加します。extensionは名前を持たなければならず(PureScriptにヒントを得た制限)、その名前は完全修飾されたメソッド名とフィールド名、あるいはasキャストで使用することができます。この名前により、曖昧さの排除が常に可能であることを保証します。

ここでは、関数型プログラミングの別の例を、様々なモナド実装で示します。

class Monoid(A) where This extends A {
  static(A) empty: A
  combine(x: A): A
}

extension Add of Int as Monoid(Int) {
  static empty = 0
  combine(x: Int): Int = this + x
}

extension Multiply of Int as Monoid(Int) {
  static empty = 1
  combine(x: Int): Int = this * x
}

extension Concat(A) of List(A) as Monoid(List(A)) {
  static empty = List.nil
  combine(suffix: List(A)) = foldRight(List\cons, suffix)

  // yes, I know, I didn't define List\foldRight
}

Monoidを受け取る関数にIntを渡したい場合は、そのMonoidのインスタンスを指定する必要があります(1 as Add, 1 as Multipl)。より一般的な使用例はfoldメソッド、つまり、listOfInts.List(Add)\fold()またはlistOfInts.List(Multiply)\fold()があります。

破壊せずにパターンマッチング

内部クラスに基づくsum型はほとんど機能しますが、代数的なデータ型のパズルには、まだ一つ欠けている部分があります。パターンマッチングについてはどうでしょう? caseクラスはパターンマッチングの破壊形状を定義していますが、通常のコンストラクタは定義しません。

パターンマッチングは純粋なOOでは機能しません。継承でsum型を作って、サブタイプ自体だけがその型を区別することができます。sum型に一致する外部関数が必要な場合、ビジターパターンを使用するか、instanceofでごまかすかどちらかになります。これは不便なので、Scalaは代わりにmatchとcaseクラスを提供し、関数的な方法でそれを行うことができます。

// Visitor pattern (awkward)

class PrintVisitor extends OptionVisitor(String) {
  visitSome(value: String) {
    print("got some ${value}")
  }
  visitNone() {
    print("got nothing")
  }
}

Some.new("foo").visit(PrintVistor.new())

// Match (easy, but not OO)

Some.new("foo") match {
  case Some(value) => print("got some ${value}")
  case None => print("got nothing")
}

理論的には、拡張メソッドを使って、Vistorパターンをサポートしていない既存のsum型にビジターパターンのようなものを作ることができます。

private class Visitor {
  visit()
}

private extension VisitOptionBase of Option as Visitor {
  visit() {}
}

private extension VisitSome of Some(String) {
  visit() {
    // Note that we're in the scope of a `Some(String)`,
    // so `value` refers to the `Some`'s public `value` field.
    print("got some ${value}")
  }
}

private extension VisitNone of None {
  visit() {
    print("got nothing")
  }
}

Some.new("foo").visit()

しかし、これではまだ多くの定型文が必要です。Scalaのmatch式のようなシンプルでありながら、内部でこれを行う新しいmatch構文を作ってみましょう。

Some.new("foo").match {
  as Some = print("got some ${value}")
  as None = print("got nothing")
}

各as句は型に一致し、その型の値のスコープ内でその本体を呼び出します。ここで魔法に注目して下さい。valueはas Someの本体のスコープ内にあり、Some\valueを参照しています。これは...混乱し過ぎるかもしれません。しかし、これはJavaで内部クラスがすでに機能している方法であり(外部クラスのスコープの上に内部クラスのスコープを重ねる)、デストラクション構文全体を必要とせずにmatchステートメントを提供しています。

また、ガードを追加して(whereやifを使って)、これらのmatchステートメントをより強力なものにすることができ、MLのようなフル機能を持ったmatchに近づけることができます。

問題

前のセクションで拡張メソッドを使った方法に、何か「違和感」を感じたかも知れません。このような使い方をするのは、良いことだと思いますし、継承やポリモーフィズムに関するOOの直感にも合致します。しかし、拡張メソッドが型クラスのように解決されるなら、それは仮想ではありません。拡張インスタンスは、オブジェクトの実行時の型ではなく、オブジェクトのコンパイル時の型に基づいて選択されるので、matchステートメントは常に未知のOptionに対して、VisitOptionBase\visitを呼び出すことになり、何もしないことになります。

このバグは、visitの例を書いているとき気づいたので、ここで解決しておこうかと思います。

技術的には、仮想拡張メソッドは必要ありません。matchと拡張メソッドの等価性は素晴らしいものだが、ほとんど意味がありません。matchは、内部でinstanceof matchingを使うだけで、作業は完了します。

しかし、仮想ではない拡張メソッドはまだ足元にも及ばないように見えます。OOで考えれば、すべてのメソッドが仮想であることを期待します。Vehicleのメソッドを定義し、それをCarの拡張子でオーバーライドし、たまたまCarであるVehicleのパラメーターでそのメソッドを呼び出した場合、オーバーライドされたメソッドが得られると期待しますが、そうではありません。

そして、仮想拡張メソッドはほとんど不可能です。菱形継承問題とは異なり、オーバーライドの重複は簡単に検出できませんし、どこからでも出てくる可能性があります。また、拡張メソッドはインポートスコープに依存しますが、スコープ内に入っていないサブタイプにはどのように適用されるのでしょうか?

実際の解決策は、拡張メソッドが既存のメソッドをオーバーライドすることを一切禁止することです。これにより、定義上は非仮想になりますが、フットガンは削除されます。上記のvisitの例は無効になりますが...

サノス: 小さな犠牲で大勢を救う

まとめ

仮想拡張方式の狂気に陥ったことを除けば、私はこの言語がどのようになっていくのかがとても気に入っています。Scalaによく似ていますが、概念的にはシンプルで、必要な機能はごくわずかです。Kotlinに似ていますが、JVMとの互換性というお荷物はありません。

ScalaとKotlinがすでに提供している機能に加えて、この言語が新たに追加した新機能は、OO互換の高次構造型、型クラスのような拡張メソッドの解決、そしてエラー処理の代替アプローチです。

このような言語がそれほど必要では無いことを、私は理解しています — この場所はそれなりに混雑しています — しかし、夢を見るのは楽しいものです。

この言語の私の仮の名前は「Lift」です。scalaははしごを意味し、エレベーターはより良いはしごです。また、「lifting」はモナド関数型プログラミングでは一般的な操作だからです。もっと良い提案があれば、私は歓迎します。