プログラミング言語論入門

第0章. なぜ Scala を使うのか?

はじめに

本稿は、John C. Mitchell 氏らによる Concepts in Programming Languages を基に自身の見解を交え、私がなぜ Scala を好んで使うのかを論じた記事になります。

プログラミング言語の歴史

本題に入る前に、プログラミング言語の歴史について紹介します。

年代 言語・イノベーション
1950 Fortran and Cobol
1960 Lisp and Algol
1970 Abstract data types (Simula, C, SQL)
1980 Objects (Smalltalk, C++)
1990 Java, JavaScript, Python, Ruby

これは、年代ごとに開発された言語およびイノベーションを表にまとめたものになります。ただし、この表には欠けている事柄があります。ヒントは、1990 年代に開発された言語におけるイノベーションです。1990 年代に開発された言語はどのような目的で開発されたのでしょうか?

1990 年の世界的な出来事といえば、ティム・バーナーズ=リー氏によって World Wide Web が実装・公開されたことになります。1990 年代に開発された言語の多くは、Web に大きく影響を受けることにより、セキュリティおよび正確性の課題に対する関心を言語設計に表しています。

年代 言語・イノベーション
1950 Fortran and Cobol
1960 Lisp and Algol
1970 Abstract data types (Simula, C, SQL)
1980 Objects (Smalltalk, C++)
1990 Web (Java, JavaScript, Python, Ruby)

そして、2000 年代にはコンピューティングデバイスの多様化が進み、安価で強力なハードウェアが利用可能となりました。それにより、2000 年代には Scala や Go、MapReduce に代表される並列分散処理が得意な言語やフレームワークが開発されました。これらが、現在のクラウドコンピューティング時代を支えるようになりました。

年代 言語・イノベーション
1950 Fortran and Cobol
1960 Lisp and Algol
1970 Abstract data types (Simula, C, SQL)
1980 Objects (Smalltalk, C++)
1990 Web (Java, JavaScript, Python, Ruby)
2000 Cloud computing (Scala, Go, MapReduce)

言語設計とトレードオフ

プログラミング言語の設計者は、解決が困難で非常に複雑なトレードオフに悩まされています。トレードオフがなければ、これほどまでに多くの言語が開発されることはなかったでしょう。例えば、一部の言語機能は、プログラムを素早く簡単に作成することを可能にしますが、その代償として、テストツールやテストメソッドの設計を難しくする場合があります。そのほかに、コンパイラによる最適化を容易にする一方で、プログラムの記述量を増加させる言語機能もあります。とりわけ、言語の表現力と実装の単純さは議論が繰り返されるトレードオフの関係の一つです。

成功したプログラミング言語の多くは、もともと特定の目的用途で設計されています。言い換えれば、目的に対して言語選択を誤ると、その恩恵を十分に得られないことがあるため注意が必要です。

次節からは、現在のクラウドコンピューティング時代を支える言語の一つである Scala に焦点を当て、その言語の特徴について紹介していきます。

Scala とは?

"Scala" という名前は、英語の "scalable language" が由来であるとされています。それでは、"scalable" を実現するために、Scala はどのような言語機能を有しているのでしょうか?

Scala の開発者らによる論文 An Overview of the Scala Programming Language には、次のように書かれています。

Scala fuses object-oriented and functional programming in a statically typed programming language. It is aimed at the construction of components and component systems.

"object-oriented" や "functional programming"、"statically typed" などの用語が見て取れます。日本語にすると、それぞれ「オブジェクト指向」、「関数型プログラミング」、「静的型付け」になります。それでは、「オブジェクト指向」とは一体何でしょうか?「関数型プログラミング」とは?「静的型付け」とは?

次章から、これらを明らかにしていきます。

第1章. なぜ関数型言語を使うのか?

関数型言語とは?

まずは、「関数型プログラミング」について説明していきます。

関数型言語関数型プログラミング言語)が何たるかを理解するには、その歴史から紐解いていく必要があります。関数型プログラミングパラダイムは最近になって広く注目されるようになりましたが、1950 年代の Lisp によって、すでにその基礎は確立されていました。Lisp には「抽象マシン」や「データとしてのプログラム」などの興味深い概念が導入されていますが、関数型言語として興味深い点は、Lisp の構文が「ラムダ計算」に基づいていたことになります。

私は、関数型言語の強みは「ラムダ計算という数学的背景に基づいている」ことに集約されていると考えています。次節からは、「ラムダ計算」を計算機科学的側面から紐解いていきます。

ラムダ計算とは?

ラムダ計算を簡単に説明すると次のようになります。

ラムダ計算はチューリングマシンと等価な計算モデル(計算模型)になります。三つの操作だけで計算を表現できるため非常にシンプルな計算モデルです。

ラムダ計算は「計算モデル」の一つであると述べられています。それでは、「計算モデル」とは一体何でしょうか?

計算モデル(計算模型)とは?

計算モデルとは、計算という行為を理論的かつ抽象的に考察するための数理モデルのことを指します。モデルによって計算可能な問題のクラスが異なります。例として、プッシュダウン・オートマトンチューリングマシン、ラムダ計算などがあります。

計算可能性理論

計算モデルによって計算可能な問題のクラスが異なるとはどういうことでしょうか?

厳密ではないですが、簡単に説明すると、解ける問題の複雑さに応じて分類されていると言うことができます。例えば、決定性有限オートマトンは、正規言語を受容できますが、それ以上複雑な言語は受容できません。同様に、プッシュダウン・オートマトンは、文脈自由言語を受容できますが、それ以上複雑な言語は受容できません。一方で、チューリングマシンは、プッシュダウン・オートマトンでも判定できない言語を判定できます。

このように、計算モデルによって解ける問題の複雑さ(今回の例では、受容可能な言語の複雑さ)が異なります。現在、チューリングマシンよりも強力な計算モデルはないと推定されています。

停止性問題

ここまで理解が深まると、チューリングマシンで解けない問題があるのかという素朴な疑問が自然と生じます。実は、チューリングマシンで解けない問題は存在し、有名なものに停止性問題があります。詳細な説明は省きますが、簡単に説明すると、自己言及のパラドックスのように、あるチューリングマシンが自身を含めて停止するかを判断しようとした時に、どうしても矛盾が生じてしまうという問題になります。

解けない問題があることを認識したところで、どのような問題なら解くことができ、どのような問題は解くことができないのかが次に気になります。

解ける問題と解けない問題

チューリングマシンで実行可能なプログラムのクラスは、帰納的関数(部分再帰関数)という数論的関数のクラスと同一であることが証明されています。つまり、数学的に帰納的関数のクラスに属する関数は、プログラムとして実装することができるが、帰納的関数のクラス以上のクラスに属する関数は、プログラムとして実装できないと読み解くことができます。

補足すると、計算可能性理論では、計算の効率が良いかどうかは問わず、計算が終わるかどうかに着目しています(関数の計算効率を研究する分野は、計算複雑性理論と呼ばれています)。

計算可能関数

同時期に、ラムダ計算で実行される関数のクラスも、同一のクラスであることが判明しています。このクラスに属する関数を、計算可能関数と呼ぶように提唱されています(cf. チャーチ=チューリングのテーゼ)。

ここで重要なことは、プログラムを数学的な関数として捉えることで、具体的な計算モデルを参照せずに、計算可能性を論じることが可能となったということになります。まさに、「手続き」から「関数」へのパラダイムシフトです。

なぜラムダ計算?

ラムダ計算がチューリングマシンと同等に強力であるのは理解ができたかと思います。それでは、なぜラムダ計算をベースにするとよいのでしょうか?

ラムダ計算の具体的な関数型プログラミング言語への応用を見る前に、次節からは、ラムダ計算の基礎に触れていきます。

ラムダ計算の基礎

ラムダ計算の主な構成要素は、ラムダ抽象(lambda abstraction)と関数適用(application)になります。具体的に見ていきましょう。

 M を何らかの式とする場合、 λx. M M x の関数として扱うことで得られる関数になります。例えば、 λx. x は恒等関数を定義しています。数学的に馴染みのある恒等関数を定義する方法は  I(x) = x ですが、この場合、関数定義のために "I" という名前を確保しなくてはなりません。

一方、ラムダ計算では名前を定義せずとも、定義した匿名関数を使用することがきます。例えば、 (λx. x)\,M と記述すれば、恒等関数を  M に適用することができます。

ここで、 λx. x がラムダ抽象(lambda abstraction)に該当し、 (λx. x)\,M が関数適用(application)に該当しています。

変数の出現は、自由か束縛かのどちらかになります。例えば、単に  x + 3 という式を書いた場合、変数  x は自由変数になります。一方、 \int f(x)\,dx という式を書いた場合、変数  x は束縛変数になります。この場合、 xプレースホルダであることを意味し、 \int f(x)\,dx \int f(y)\,dy と書いても同様の意味を成します。

ラムダ式においても同様に、 λx. x と書いた場合、変数  x は束縛変数になります。この場合、 λx. x λy. y に置き換えることができます。

それでは、式  λx. (λy. xy)\,y はどう解釈すればよいでしょうか?一般のプログラミング言語が変数のスコープを持つように、ラムダ式にもスコープという概念が存在しています。例えば、式  λx. M では、式  M λx のスコープと呼ばれます。式  M に変数  x が出現するのであれば、変数  x は束縛変数であり、そうでなければ、変数  x は自由変数になります。

 λx. (λy. xy)\,y における、左から二番目の変数  x は、 λx によって束縛された束縛変数になります。同様に、左から二番目の変数  y は、 λy によって束縛された束縛変数になります。一方、左から三番目の変数  y は、括弧で閉じられた  λy のスコープの外で出現しています。したがって、左から三番目の変数  y は、 λy によって束縛されていない自由変数になります。

α-equivalence

この混乱を避けるために、ラムダ計算における基本的な公理の一つである α-equivalence を利用することができます。α-equivalence の基本的なアイディアは、束縛変数の名前は重要ではないということになります。ゆえに、先ほどの式  λx. (λy. xy)\,y は、 λx. (λz. xz)\,y のように書き換えることで、理解を容易にすることができます。

α-equivalence は、次の式で表されます。


λx. M = λy. [y/x]M

ここで、 [y/x]M は、式  M における変数  x の自由出現を変数  y に置き換えた結果を表しています。 ただし、変数  y は式  M に出現していないものとします。

β-equivalence

ラムダ計算における他の基本的な公理の一つに、β-equivalence があります。β-equivalence は、ラムダ計算における重要な構成要素である関数適用を、代入により計算するために用いられます。 (λx. M)\,N は、これまで見てきたように、式  M x の関数として扱うことで得られる関数に  N を適用したものであるので、式  M x N を代入することで同じ値を得ることができます。

β-equivalence は、次の式で表されます。


(λx. M)\,N = [N/x]M

関数型言語とラムダ計算の関係

ラムダ計算の基礎について少し丁寧に説明しましたが、ラムダ計算が単純な関数型のプログラミング言語に見えてきた頃だと推察します。そろそろ、ラムダ計算の関数型言語への応用に話を進めます。

カリー化と部分適用

これまで、ラムダ抽象によって式  M x の関数として扱うことができること見てきました。それでは、式  M x y の二変数の関数として扱うことは可能でしょうか?

答えは Yes です。 λx. (λy. M) というラムダ式を書くことで目的を達成できます。同様に、関数  f(g, x) = g(x) のような二変数関数は、ラムダ計算では  f _ {curry} = λg. λx. gx によって表現されます。関数  f と関数  f _ {curry} の違いは、関数  f がペア  (g, x) を引数に取るのに対し、関数  f_{curry} が唯一の引数を取る点にあります。それにもかかわらず、関数  f _ {curry} が関数  f の代わりに利用できるのは、 f _ {curry}\,h\,y = (f _ {curry}\,h)\,y = hy = f(h, y) のように、ラムダ計算が値に関数を適用した結果として関数を取得できるためです。このような多変数関数を一変数関数に分解するテクニックは、カリー化(currying)と呼ばれています。

このカリー化は、プログラミングにおいてどのような利点があるのでしょうか?

次のような Scala のプログラムを考えてみます。

val xs = List(1, 2, 3)
val ys = xs.map { x => 1 + x }
val zs = ys.map { y => 1 + y }

これでも十分シンプルではありますが、カリー化を使うことで次のように書くこともできます。

def add(x: Int)(y: Int): Int = x + y
val add1 = add(1)

val xs = List(1, 2, 3)
val ys = xs.map(add1)
val zs = ys.map(add1)

この例では、add(1) により y => 1 + y と等価な関数が生成されており、生成された関数は add1 に束縛されています。このように、ある関数を雛形に引数をカスタマイズした関数を生成できる点がカリー化の利点になります。カリー化された多変数関数を一部の変数に対してのみ適用することは、部分適用(partial application)と呼ばれています。

再帰関数

プログラマであれば、探索問題を解くために再帰関数を記述するアプローチを取ったことあったでしょう。ラムダ計算における匿名関数を用いて再帰関数を取得することは果たして可能でしょうか?

実は、とあるトリックを使用すれば、再帰関数を書くことができます。詳細な説明は省きますが、関数の不動点を求めることで再帰関数を取得するというアプローチになります。興味のある方は、「不動点コンビネータ」あるいは「Y コンビネータ」で調べてみることを推奨します。

高階関数

高階関数とは、関数を引数として受け取るか、結果として関数を返す(またはその両方)関数を意味しています。関数を引数として受け取る例としては、多くの言語でリスト操作のために使用される map 関数や filter 関数などが代表的です。

「カリー化と部分適用」の節で見た Scala のプログラムをもう一度見てみましょう。

def add(x: Int)(y: Int): Int = x + y
val add1 = add(1)

val xs = List(1, 2, 3)
val ys = xs.map(add1)
val zs = ys.map(add1)

プログラム中で map メソッドが呼ばれています。実際に map メソッドの実装を見てみると、次のように実装されています。

final override def map[B](f: A => B): List[B] = {
  if (this eq Nil) Nil else {
    val h = new ::[B](f(head), Nil)
    var t: ::[B] = h
    var rest = tail
    while (rest ne Nil) {
      val nx = new ::(f(rest.head), Nil)
      t.next = nx
      t = nx
      rest = rest.tail
    }
    releaseFence()
    h
  }
}

引数に注目してみましょう。f: A => B とあります。Scala の構文を知らない方のために説明しますと、これは、型 A の値を引数に取り型 B の値を返す "f" という名前の関数を引数に取ると宣言しています。実際に、一つ目のプログラムでは、map メソッドに add1 という Int 型の値を引数に取り Int 型の値を返す関数を引数として渡しています。

関数を返す関数はどのような関数でしょうか?

実は、add 関数がそれに該当します。特殊な構文が用いられていますので、Scala を知らない方にもわかりやすい書き方にしてみます。

def add(x: Int): Int => Int = {
  def f(y: Int): Int = x + y
  f
}

この add 関数は、カリー化版の add 関数と同様の使い方ができます。add 関数内で定義されているローカル関数 f が、add 関数の評価値として返されています。ローカル関数 f のような関数は、「クロージャ」と呼ばれています。実際には、「クロージャ」という用語は言語によって異なるものを指しています。

第2章. なぜオブジェクト指向言語を使うのか?

オブジェクト指向の二つの起源

続いて、「オブジェクト指向」について説明していきます。

オブジェクト指向プログラミングの起源というと、Smalltalk という言語を思い浮かべる方も多いかと思います。確かに、Smalltalkオブジェクト指向という考え方を世に広めた言語として知られています。しかし、オブジェクト指向機能としておなじみの「クラス」や「オブジェクト」という言語機能を有していたのは、Smalltalk が初めてではありません。これらの機能は、1960 年代後半、スウェーデンのクリステン・ニガルド氏らによって Simula という言語に導入されたものです。ただし、当時はまだ「オブジェクト指向」という考え方はありませんでした。後に、Simula の言語機能は、ビャーネ・ストラウストラップ氏によって C++ に導入され、「オブジェクト指向」として広く使用されるようになりました。

一方、アラン・ケイ氏は、Simula の「オブジェクト」を、彼の「メッセージングを介した動的システム構築」というアイデアにおける「レシーバ」(あるいはシステムの構成要素)として利用できることに気付き、「メッセージングのオブジェクト指向」を暫定ダイナブック環境である Smalltalk で実践して見せました。彼のアイデアには必ずしも「クラス」の概念は必須というわけではありませんでしたが、Simula スタイルのクラスやその継承機構などは、後の Smalltalk に取り込まれました。

次節からは、後に「オブジェクト指向」と称されるようになった言語機能を用いることで、彼らがどのような課題の解決を図ったのかを明らかにしていきます。

モジュール性と抽象性

多くのプログラマは、ソフトウェアやシステムをモジュールの組み合わせで構築することに価値があることを長い間認識してきました。設計が効果的になされていれば、各モジュールは個別にテストを行うことができます。モジュール性の重要な二つのゴールは、一つのモジュールを他のモジュールの詳細に関する知識がなくても書くことができること、および、システムの他の部分を変更せずにモジュールを再設計および再実装できるようにすることです。

プログラミング言語とソフトウェア開発環境は、様々な方法でモジュール性をサポートしています。手始めに、オブジェクト指向以前のモジュール性メカニズムについて触れておきましょう。

構造化プログラミング

1969 年に、プログラムの構造についてエドガー・ダイクストラ氏による論文で重要な主張がなされました。主張内容は、初めに達成したいタスクの概要を書き、そのタスクを小さなサブタスクに分割することを、分割されたタスクが簡単な表現に落とし込まれるまで繰り替えすというものです。これにより、理解できるほど小さく独立して解決できるほど十分に小さいサブ問題が生成されます。彼は、このことを「構造化プログラミング」と称しました。

少し補足しておきますと、「構造化プログラミング」という用語は、日本では「順次・選択・反復」の三つの手段であらゆる制御フローを表現できることであると広く認識されています。しかし、本来これらはコラド・ベーム氏とジュゼッペ・ヤコピーニ氏による「構造化定理」(あるいは「ベーム・ヤコピーニの定理」)のことを指しています。ここでは、エドガー・ダイクストラ氏による「構造化プログラミング」を扱います。

まずは、単純な例を見てみましょう。以下に示すのは、エドガー・ダイクストラ氏が検討した、素数の初めの 1000 個を計算し出力する仮想的なプログラムです。

begin
  print first thousand prime numbers
end

タスクをサブタスクに分割しましょう。

begin variable table p
  fill table p with first thousand primes
  print table p
end

さらに分割します。

begin integer array p[1:1000]
  make for k from 1 through 1000
    p[k] equal to the k-th prime number
  print p[k] for k from 1 through 1000
end

ここまで来ると、基本的なプログラム構造が決定され、プログラマは連続する素数を計算するアルゴリズムの考案に集中することができるようになります。この例は非常に単純ではありますが、段階的な改良によるプログラミングの基本的な考え方をある程度理解できるはずです。段階的な改良は一般に、木構造のような概念構造を持つプログラムの作成につながります。

1970 年代以降、多くのプログラム開発手法が提案されてきましたが、その一つに「プロトタイプ」と呼ばれる、今日のアジャイル開発にも継承されている手法があります。プロトタイプは、目的を達成するプログラムの一部を簡単な方法で実装し、設計が実際に機能するかどうかの確認およびテストを実施した上で、本格的な実装を行う開発手法です。これにより、プログラムの一部を独立して開発することが可能になります。これは、オブジェクト指向にも引き継がれています。

分割統治はコンピュータサイエンスの基本的な手法の一つです。ソフトウェアシステムは非常に複雑になる可能性があるため、プログラムを個別に処理可能なパーツに分割することが重要です。構造化プログラミングのようなトップダウンアプローチは、有効にはたらく場合にはプログラムを適切な粒度のパーツに分離することができます。しかし、トップダウンアプローチが有効にはたらかない場合もあります。トップダウンアプローチの難しいところは、問題をサブ問題に分割するときに、より簡単な問題に分割しなくてはならない点にあります。これは、事前に問題の解き方について良いアイディアを持っている必要があることを意味します。そのため、基本的なパーツを設計してから大規模なシステムを構築する、ボトムアップアプローチも場合によっては必要になります。

さらに、より複雑なシステムでは、手順だけでなくデータ構造も洗練する必要があります。例えば、メインプログラムが入力を受け付けサブプログラムを呼び出すデザインのシステムでは、ユースケースが追加されることで、サブプログラム間の橋渡しを行うデータ構造への要求が変更されます。その結果、サブプログラムそのものの動作の変更が必要になります。

次項では、モジュール性を強化する抽象性の、プログラム言語によるサポートについて明らかにしていきます。

モジュール性のための抽象性

多くのプログラマやソフトウェアアーキテクトは、プログラムの「適切な抽象化」について議論を繰り返しています。適切な抽象化とは、データ構造や処理メタファ(あるいはイディオム)などの一般的な概念を利用し、複雑で詳細な問題をより規則的または体系的に見せることを意味しています。プログラミング言語がサポートする抽象化メカニズムは、プログラムを特定の詳細を含む部分と詳細が隠されている部分に分離し、詳細を隠すメカニズムです。これにより、コンポーネントの利用は特定のインタフェースに制限されます。

最も古い抽象化メカニズムの一つは、「プロシージャ」もしくは「関数」です。関数のインターフェースは、関数名および引数の数と型、戻り値の型から構成されます。このインタフェースを境界として詳細が隠されています。例として、階乗を計算する Scala のプログラムを見てみましょう。

def fact(n: Int): Int = {
  require(0 <= n)
  if (n == 0) return 1
  var u = 1
  for (v <- 1 to n) u *= v
  u
}

この fact 関数は、古典的な for ループを使用して階乗を計算しています。この関数のインタフェースは、関数名の "fact" および引数の型の Int 型、戻り値の型の Int 型によって構成されています。fact 関数を実装するプログラマは、これらを変更しない限り実装の詳細を変更することが可能です。例えば、次のように変更することが可能です。

def fact(n: Int): Int = {
  require(0 <= n)
  if (n == 0) return 1
  (1 to n).reduce { (u, v) => u * v }
}

この fact 関数は、再帰スキームを提供する reduce メソッドを使用して実装しています。他にも、再帰関数や product メソッドを使うなど、様々な方法が考えられるでしょう。

処理がプロシージャもしくは関数というメタファによって抽象化されるように、データも抽象化されることで、データの表現方法に関する情報が隠されます。データ抽象化メカニズムの一つに、「抽象データ型宣言」があります。

抽象データ型

データの抽象化に対する関心は、1970 年代の「拡張可能な」言語を調査する動きの中で顕著になりました。これにより、抽象データ型宣言と呼ばれるプログラミング言語構成要素が開発されました。

抽象データ型は、指定された一連の操作と型から構成されます。抽象データ型宣言のメカニズムを持つ言語を用いることで、プログラマはデータ型と操作をグループするだけでなく、データ構造の詳細へのアクセスを制限することができます。これは、データ型の使用をインタフェースに制限可能であることを意味します。

例えば、データ構造の「スタック」を配列で実装した場合、スタック抽象データ型を使用するクライアントプログラムは、プッシュやポップなどのスタック操作は使用できるものの、インデックスを用いた配列への直接アクセスなどは制限されます。これにより、データ構造の実装者は、データ構造を使用するプログラムに影響を与えることなく変更を加えることができます。以下は、C でスタック抽象データ型を実装する例です。

  • stack.h
#ifndef INCLUDED_STACK_H
#define INCLUDED_STACK_H

#include <stddef.h>

struct stack_t;
typedef struct stack_t stack_t;

void init(stack_t *p_stack, size_t size);
void push(stack_t *p_stack, int data);
int pop(stack_t *p_stack);
void fini(stack_t *p_stack);

#endif
  • stack.c
#include "stack.h"

struct stack_t {
  size_t max_num;
  size_t num;
  int *p_data;
};

void init(stack_t *p_stack, size_t size) { ... }
void push(stack_t *p_stack, int value) { ... }
int pop(stack_t *p_stack) { ... }
void fini(stack_t *p_stack) { ... }

stack.h を通じてスタック抽象データ型を使用するクライアントプログラムは、push 関数や pop 関数を通じて stack_t 構造体を操作することができます。一方、stack_t 型は "opaque" な型として宣言されているため、クライアントプログラムは stack_t 構造体の詳細へのアクセスが制限されます。

このメカニズムはセキュリティの側面から見ても非常に強力です。そして、オブジェクト指向言語を使用すれば、より洗練された抽象化メカニズムを使用することができます。次節からオブジェクト指向におけるメカニズムを見ていきます。

オブジェクト指向

オブジェクト指向の歴史の始まりは、1967 年の Simula 67 である。およそ 50 年というプログラミング言語の歴史としては比較的長い期間は、オブジェクト指向を主要なソフトウェア設計および実装戦略へと成長させました。本節では、オブジェクト指向を構成する四つの重要な概念を紐解いていきます。

「オブジェクト」は、隠蔽されたデータに対する一連の操作で構成されます。オブジェクトの重要な特徴は、データと機能の組み合わせをカプセル化する均一な方法を提供することです。オブジェクトには、単一の整数値からデータベースまで様々な大きさがあります。オブジェクトの大きさに関わらず、オブジェクトとのすべての対話は、メッセージまたはメンバー関数(メソッド)呼び出しと呼ばれる単純な操作によって行われます。

Java に代表されるオブジェクト指向言語が商用的に受け入れられたことで、オブジェクト指向という用語が様々な言語で様々な言語機能に対して用いられるようになり、様々な言語機能がオブジェクト指向として誤解され、混乱を招いています。そのため、言語機能を区別して議論することは有意義です。本節では、オブジェクト指向言語という用語は、オブジェクトと、次項から説明する四つの言語機能(動的束縛、カプセル化、サブタイピング、継承)を備えたプログラミング言語のことを指すものとします。

オブジェクト指向設計は、オブジェクトをシステム内のコンポーネントまたは概念に関連付けることに基づく反復的なプロセスです。「反復的」が意味することは、多くの細かなプロシージャを呼び出すことでプロシージャを実装するのと同じように、多くの細かなオブジェクトを使用してオブジェクトを実装するということです。「構造化プログラミング」の項で、タスクが複雑になると、プログラムの手続きとデータ構造の両方を同時に改良する必要があることに言及しました。オブジェクトは関数とデータの組み合わせであるため、オブジェクト指向言語を使用することで、プロシージャ指向言語よりも効果的に関数とデータを同時に改良することが可能になります。

すべてのオブジェクト指向言語は、何らかの方法でオブジェクトを表現します。すでに述べたように、オブジェクトは関数とデータで構成され、特定のインターフェイスを介してのみアクセスが可能です。 Smalltalk や C ++、Java などの一般的なオブジェクト指向言語では、オブジェクトの実装はそのクラスによって決まります。これらの言語では、クラスのインスタンスを生成することでオブジェクトが生成されます。オブジェクトの関数部分はメソッドまたはメンバ関数と呼ばれ、オブジェクトのデータ部分はインスタンス変数またはフィールドと呼ばれます。オブジェクトとクラスを備えたプログラミング言語は、通常、動的束縛およびカプセル化、サブタイピング、継承の言語機能を提供します。次項から、これらの言語機能について説明していきます。

動的束縛

カプセル化

サブタイピング

継承

GUI と OOUI

ポリモーフィズム

ポリモーフィズムと聞いて最近のオブジェクト指向言語を使用するプログラマが連想するのは、多くの場合、サブタイピング多相と呼ばれる種類のポリモーフィズムになります。実は、これはプログラミング言語で扱われる多相性の一つに過ぎません。この他に、アドホック多相やパラメトリック多相(もしくはパラメータ多相)という多相性が扱われています。次項から、それぞれ明らかにしていきます。

サブタイピング多相

サブタイピング多相は、ある型の値が別の型の値として扱えることを指します。

例えば、人間を構造化した Person 型と、動物を構造化した Animal 型があったとします。人間は動物の一種ですので、作成した Person 型の持つメソッドは、Animal 型の持つメソッドを全て網羅していました。このとき、Person 型の値は Animal 型の値として利用することができます。サブタイピングの利点はこれだけではありません。猫を構造化した Cat 型や犬を構造化した Dog 型の値も、必然的に Animal 型の値として扱うことができます。様々な動物を操作する際のインタフェースを、Animal 型に統一することができるのです。

これは、インタフェースとしての型を満足する型は複数存在し、それらを取り替えることが可能であることも意味しています。例えば、データ構造のマップのインタフェースを提供する Map 型の値として、ハッシュ値を用いた実装を持つ HashMap 型の値や木構造を用いた実装を持つ TreeMap 型の値を使用することができ、Map 型をインタフェースとして使用するユーザはそれらを意識することなく取り替えることが可能です。

このように、サブタイピング多相は拡張性を提供します。しかし、その拡張性は十分ではありません。次のようなシナリオを考えましょう。

図形屋さんの Shape インタフェース(トレイト)とそれを実装するサブクラスは、ドロー屋さん向けに、広く知られている Drawable インタフェース(トレイト)を実装していました。

trait Drawable {
  def path: Path
}

trait Shape extends Drawable {
  def x: Double
  def y: Double
}

class Circle(
  override val x: Double,
  override val y: Double,
  val radius: Double
) extends Shape {
  override def path: Path = ...
}

class Triangle(
  override val x: Double,
  override val y: Double,
  val width: Double,
  val height: Double
) extends Shape {
  override def path: Path = ...
}

class Rectangle(
  override val x: Double,
  override val y: Double,
  val width: Double,
  val height: Double
) extends Shape {
  override def path: Path = ...
}

ある日、ドロー屋さんから、「いつも使わせてもらっているよ。ところで、Star クラスを追加してもらうことってできないかな?」との要望をいただきました。この要望に応じるのは容易なことでした。Shape インタフェース(トレイト)を実装する Star クラスを作れば事足りたのでした。オープン・クローズド原則に従えていたのです。

class Star(
  override val x: Double,
  override val y: Double,
  val width: Double,
  val height: Double
) extends Shape {
  override def path: Path = ...
}

またある日、今度はペイント屋さんから、「君のとこの Shape インタフェース(トレイト)とそのサブクラス、とても良くできているから Paintable インタフェース(トレイト)も実装してくれないかい?」との要望をいただきました。しかし、サブタイピングの世界に生きている図形屋さんは困り果ててしまいました。その要望に応じるには、Shape インタフェース(トレイト)に Paintable インタフェース(トレイト)を継承させそのサブクラスすべてを変更する(方法 A)か、新たに Shape インタフェース(トレイト)をラップする PaintableShape インタフェース(トレイト)を作成する(方法 B)しか方法がなかったのでした。

  • 方法 A
trait Paintable {
  def dots: Dots
}

trait Shape extends Drawable with Paintable { ... }

class Circle(...) extends Shape {
  ...
  override def dots: Dots = ...
}

class Triangle(...) extends Shape {
  ...
  override def dots: Dots = ...
}

class Rectangle(...) extends Shape {
  ...
  override def dots: Dots = ...
}

class Star(...) extends Shape {
  ...
  override def dots: Dots = ...
}
  • 方法 B
trait Paintable {
  def dots: Dots
}

trait PaintableShape extends Shape with Paintable

class PaintableCircle(
  override val x: Double,
  override val y: Double,
  val radius: Double
) extends PaintableShape {
  val circle: Circle = ...
  override def path: Path = this.circle.path
  override def dots: Dots = ...
}

class PaintableTriangle(
  override val x: Double,
  override val y: Double,
  val width: Double,
  val height: Double
) extends PaintableShape {
  val triangle: Triangle = ...
  override def path: Path = this.triangle.path
  override def dots: Dots = ...
}

class PaintableRectangle(
  override val x: Double,
  override val y: Double,
  val width: Double,
  val height: Double
) extends PaintableShape {
  val rectangle: Rectangle = ...
  override def path: Path = this.rectangle.path
  override def dots: Dots = ...
}

class PaintableStar(
  override val x: Double,
  override val y: Double,
  val width: Double,
  val height: Double
) extends PaintableShape {
  val star: Star = ...
  override def path: Path = this.star.path
  override def dots: Dots = ...
}

このように、サブタイピング多相は、要素が追加される可能性がある場合には有効にはたらくが、振る舞いが追加される可能性がある場合には有効にはたらかない場合があります。

実は、アドホック多相はこの逆の性質を示します。次項で具体的に見てみましょう。

アドホック多相

アドホック多相は、ある関数が型階層上は関係のない複数のデータ型に対して適用可能であることを指します。

この言語機能は、Java では「オーバロード」と呼ばれています。Java での例を紹介します。

final class Helper {
  public static String toString(Integer i) { ... }
  public static String toString(Double d) { ... }
}

上記により、toString 関数が Integer 型の値と Double 型の値の両方に適用可能となります。しかし、Java のオーバロードはアドホック多相性の一部分しか実現していません。関数型プログラミングの影響を受けた言語では、アドホック多相性の利点が最大限に引き出されます。

例えば、有理数を扱う Rational クラスを自身で設計したとしましょう。このとき、標準ライブラリに含まれる数値を扱う型の値から、Rational 型の値に変換を行うメソッドがあると便利に思うかもしれません。例えば、Int 型の値から Double 型の値に変換を行う場合、Scala では Int 型の値が持つ toDouble メソッドを呼び出すことで目的を達成できます。同様のマナーで、Int 型の値が toRational メソッドを呼び出すことで Rational 型の値に変換されたら扱いやすいです。しかし、関数型プログラミングの影響を受けていない言語でこれを実現するのは困難になります。標準ライブラリのクラスを再設計しなくてはなりません。そうでなければ、マナーを捨ててヘルパ関数を用意する必要があります。

一方、関数型プログラミングの影響を受けている言語であれば、Int クラスを再設計せずとも、toRational メソッドを実装させることができます。Scala では次のように書くことで実現できます。

class Rational(val n: Int, val d: Int) {
  override def toString: String = s"${this.n} / ${this.d}"
}

object Rational {
  def apply(n: Int, d: Int) = new Rational(n, d)

  implicit class IntWithToRational(i: Int) {
    def toRational: Rational = Rational(i, 1)
  }
}

implicit class を書くことで、Int 型に toRational メソッドを実装させています。これにより、次のようなプログラムが正常に動作するようになります。

val i = 5
val r = i.toRational
println(r) // -> 5 / 1

同様の方法で、様々な数値クラスに toRational メソッドを実装させることができます。このように、後付けで様々な振る舞いを行わせることができることが、「アドホック」と呼ばれる所以です。後付けできることを持ってアドホック多相とは呼ばないとの批判があるようですが、Haskell のようなデータ型と振る舞いが分離されている言語では、アドホック多相性を利用して、結果的にインタフェースとしての関数が複数のデータ型の値に対しての適用を後付けでサポートするようになります。

ここで、サブタイピングの時と同様のシナリオを考えてみましょう。ただし、今回は直和型を用います。

図形屋さんの Shape 型は、ドロー屋さん向けに、広く知られている Drawable 型クラスのインスタンスでした(本来、アドホック多相性は型クラスと切り分けられますが、実装上の問題で型クラスを用いています)。

trait Drawable[T] {
  def path(v: T): Path
}

sealed abstract class Shape

case class Circle(
  x: Double,
  y: Double,
  radius: Double
) extends Shape

case class Triangle(
  x: Double,
  y: Double,
  width: Double,
  height: Double
) extends Shape

case class Rectangle(
  x: Double,
  y: Double,
  width: Double,
  height: Double
) extends Shape

implicit def DrawableShape = new Drawable[Shape] {
  def path(v: Shape): Path = v match {
    case Circle(x, y, radius) => ...
    case Triangle(x, y, width, height) => ...
    case Rectangle(x, y, width, height) => ...
  }
}

ある日、ペイント屋さんから、「君のとこの Shape 型、とても良くできているから Paintable 型クラスのインスタンスにできないかい?」との要望をいただきました。この要望に応じるのは容易なことでした。Paintable 型クラスのインスタンス宣言を記述するだけで事足りました。

trait Paintable[T] {
  def dots(v: T): Dots
}

implicit def PaintableShape = new Paintable[Shape] {
  def dots(v: Shape): Dots = v match {
    case Circle(x, y, radius) => ...
    case Triangle(x, y, width, height) => ...
    case Rectangle(x, y, width, height) => ...
  }
}

またある日、ドロー屋さんから、「いつも使わせてもらっているよ。ところで、Star コンストラクタを追加してもらうことってできないかな?」との要望をいただきました。しかし、型クラスとアドホック多相の世界に生きている図形屋さんは困り果ててしまいました。その要望に応じるには、パターンマッチを変更してケースを追加するしか方法がなかったのです。

case class Star(
  x: Double,
  y: Double,
  width: Double,
  height: Double
) extends Shape

implicit def DrawableShape = new Drawable[Shape] {
  def path(v: Shape): Path = v match {
    ...
    case Star(x, y, width, height) => ...
  }
}

implicit def PaintableShape = new Paintable[Shape] {
  def dots(v: Shape): Dots = v match {
    ...
    case Star(x, y, width, height) => ...
  }
}

このように、アドホック多相は、振る舞いが追加される可能性がある場合には有効にはたらくが、要素が追加される可能性がある場合には有効にはたらかない場合があります。

オブジェクト指向言語から導入されたサブタイピング多相と関数型言語から導入されたアドホック多相が、それぞれ異なる課題を解決していることを理解いただけたかと思います。この事実は、どちらがより優れていることを表しているのではありません。むしろ、変化しやすいものを見極めて、場合によって使い分けることが重要であることを表しています。いずれにしても、どちらのアプローチも選択肢として取ることができる Scala は、非常に強力な言語であると私は主張いたします。

パラメトリック多相

フレームワークとライブラリ

第3章. なぜ型システムが必要なのか?

型システム

型理論

型モデル

型安全

型安全とは何か?

型安全は、「正しく型付けされている ⇒ 不正な動作をしない」ことを保証します。

不正な動作とは、未定義の動作のことを指します。例えば、C や C++ では、以下のようなプログラムは動作環境によって結果が異なり、これは未定義の動作とされています。

char buf[64];
buf[64] = 'a'; // the behavior is undefined

上記の例より、正しく型付けされているのにも関わらず未定義の動作を引き起こす C や C++ は、型安全な言語ではないと言えます。

型安全な言語は?

それでは、型安全な言語にはどのような言語があるのでしょうか?

例えば、Java は型安全な言語です。配列の領域外にアクセスをすると ArrayIndexOutOfException という実行時例外が発生することが保証されていますし、o.m() という記述が正しく型付けされているのであれば、オブジェクト o がメソッド m を持つことが保証されています。o の値が null の場合においても、NullPointerException という実行時例外が発生することが保証されています。

Ruby も型安全な言語です。整数型と文字列型を連結しようとすると例外処理が実行されます。o.m() という記述は、オブジェクト o がメソッド m を持つのであれば、メソッドの呼び出しは成功し、持たないのであれば、例外エラーが発生することが保証されています。意外に思うかもしれませんが、これは言語のセマンティクスが定義していることであり、未定義の動作とは大きく異なります。

このように、型安全とはいえ保証される「定義される動作」が様々であるため、型安全だから必ずしも嬉しいとは限りません。

Java のような静的型付け言語の嬉しいところは、コンパイル時にある程度型のミスマッチなどを確認してくれるところにあります。 では、なぜ Java は実行時例外による動作定義を排除できないのでしょうか?(例えば、「配列の領域外へのアクセスは、ArrayIndexOutOfException という実行時例外を発生させる」と定義されています。)コンパイル時に判断することはできないのでしょうか?

プログラムの正しさ

以前にも停止性問題について触れましたが、プログラムの間違いをコンパイル時に全て見つけることができないことは、数学的に証明されています(cf. ゲーデル不完全性定理、ライスの定理)。数学的に証明されているため、どれだけ技術が進歩しても、プログラムの正しさを 100% 保証することはできません(機械学習によるアプローチも、有用ではあるが絶対ではありません)。

型推論