「Scala言語らしさ」を理解しよう! オブジェクト指向と関数型プログラミングの融合とは?
プログラミング言語Scalaの設計思想にあるという、オブジェクト指向と関数型プログラミングの融合(fusion)という理想と、それを掲げつつも現実主義的な点について、水島宏太(kmizu)さんが解説します。
kmizuと申します。株式会社ドワンゴでエンジニアを務めています。
最近では、毎年の新卒エンジニア向けScala研修の講師や、N予備校 プログラミングコースの一部教材のレビューといった教育、および研究等の面でも活動しています。
ドワンゴでは、私が入社した時点でScalaがかなり採用されており、社内にScalaをより深く広めることも職務の一環でした。私は2007年くらいの、Scalaがまだほとんど注目されていなかった頃からScalaを触り始めており、その縁で新卒エンジニア向けのScala研修資料作成にもメイン執筆者として携わることになりました。また、それ以外でも、その経歴を買われてさまざまな場所でScalaに関する発表を行っています。
今回の記事執筆依頼が来たときは、私よりもScalaエンジニアとしての技量が高い他の方のほうが向いているのではと思いましたが、ことScalaの設計思想に関しては誤解されがちなので、昔からScalaを追ってきた私がScalaの入門記事を書くのもいい機会になるように思えました。
本稿では、Scalaの誕生・進化の経緯を追うことで、Scalaの設計思想を俯瞰していきます。あわせて、私がScalaを書くときに気を付けていることや、Scalaらしい書き方をお伝えしていきたいと思います。
The Scala Programming Language
言語設計の思想に見られるScalaらしさ
実は「Scalaらしさ」を語るのは、けっこう難しいことです。Scalaの設計者が語る設計思想は書籍やインタビュー記事などを通じて知ることができるのですが、実際の使われ方がそこからしばしば逸脱することがあるからです。
特に最近は、純粋関数型プログラミングに近いことをScalaで行おうとする人たちがいて、オブジェクト指向派の人と対立したりすることもあります。とはいえScalaらしさを語るには、言語設計者の設計思想を知るのが一番です。
Scalaの作者=Martin Odersky先生について
Scalaの設計思想を知るには、作者であるMartin Odersky(マーティン・オダースキー)先生のバックグラウンドや、Scala以前に開発した言語についても掘り下げる必要があります。
Martin Odersky先生は、スイス連邦工科大学ローザンヌ校(EPFL)の教授を務めています(「先生」という敬称をつけているのはそのためです)。彼の専門の研究分野は、型理論と関数型プログラミングです。
型理論というのは、パッと見で何をする学問なのかよく分からないかもしれません。型理論は、プログラミング言語等の静的型を対象として、その基盤と応用について研究する学問です。「Java and scala's type systems are unsound: the existential crisis of null pointers」という、JavaとScalaの型システムが実は「安全でない」ことを示した論文が2016年に発表されましたが、例えばこうした研究も型理論の範疇に入ります。
ちょっと話が逸れましたが、彼は型理論についての研究者であり、また、オブジェクト指向と関数型の融合というテーマに取り組んできたために、Scalaにもそういった理論面での蓄積やオブジェクト指向を重視した跡が見られます。
誕生前夜~PizzaとGJ、Funnel
Odersky先生は、型理論や関数型プログラミングの研究をもとに、現在のScalaにつながる重要なプログラミング言語を開発しています。PizzaとFunnelです。PizzaはJavaに高階関数や代数的データ型、ジェネリクスなどを取り入れた、当時としては非常に先進的な言語でした。
Set<String> s = new TreeSet( fun(String x, String y) -> boolean { return x.compareTo(y) < 0; } ); for (int i = 1; i < args.length; i++) s.include(args[i]); System.out.println(s.contains(args[0]));
Pizzaのジェネリクスは、当時Sun Microsystemsに居たJavaチームのメンバーに刺激を与え、Odersky先生やSun MicrosystemsのJavaチームの人たちが中心となって、GJというJavaにジェネリクスを追加するプロジェクトが始まりました。GJプロジェクトは1998年には成果を出しており、成果物であるGJコンパイラがSun Microsystemsに買収されるという形で、後のJavaへのジェネリクス追加につながりました。
GJが成功を収めた一方で、Odersky先生は、Java言語の後方互換性を維持しつつジェネリクスを追加するという作業に限界を感じていたようです。そのためか、GJプロジェクトの後にOdersky先生は、Funnelという別のプログラミング言語を設計することになります。
Funnelは、Functional Netsという並行計算のための理論的基盤を持ち、かつ非常にシンプルな言語仕様で多様なものごとを表現できることを目指したプログラミング言語でした。 以下のFunnelコードは、1要素だけを持つバッファオブジェクトを定義したものです。
def newBuffer = { def get & full x = x & empty; def put x & empty = () & full x; (get, put) & empty }; // オブジェクトの「定義」 val (get', put') = newBuffer; //オブジェクトの「生成」
Funnelはシンプルな言語仕様でさまざまなものごとを実現できる反面、やりたいことをFunnelのコードに落とし込むのに多くの手順が必要であり、また、実用性を重視していなかったために、あまり受けは良くなかったようです。
そこで、GJの成功とFunnelの失敗を教訓にして開発された言語が、Scalaなのです。ScalaはGJ(の実用性)とFunnel(のシンプルさ)の中間を目指して設計されました。
このあたりの経緯についてはOdersky先生へのインタビュー記事「The Origins of Scala」に詳しいです。
Scalaの設計思想
Scalaの設計思想を理解する上で絶対に外せないのが、オブジェクト指向プログラミングと関数型プログラミングの融合(fusion)という思想です。この思想については多くの人が誤って理解していますが、オブジェクト指向プログラミング「も」できるし、関数型プログラミング「も」できるというハイブリッド(hybrid)アプローチではありません。
オブジェクト指向プログラミングと関数型プログラミングの融合という思想を正確に理解するには、オブジェクト指向プログラミングと関数型プログラミングは対立するものではなく直交する(直接関係がないため、自由に組み合わせられる)ものであることを知る必要があります。
読者の方の中には、状態を持つオブジェクト指向プログラミングと、状態を持たない関数型プログラミングは相反するものではないのかという疑問を持たれる方がいるかもしれません。しかし、よく考えてほしいのですが、オブジェクト指向プログラミングでもValue Objectパターンなど不変性を重視したパターンがいくつもありますし、Javaなどの標準ライブラリにもそのような不変オブジェクトを採用したものがあります。
本来、関数型プログラミングと対立するのは、手続き型(状態あり)とオブジェクト指向プログラミングの組み合わせであって、オブジェクト指向プログラミングが関数型プログラミングと対立しているわけではないのです。
主流のオブジェクト指向言語が概ね手続き型とオブジェクト指向プログラミングの組み合わせを手法として採用しているのでピンと来ないかもしれませんが、アカデミックな世界では、昔からオブジェクト指向プログラミング言語を関数型の理論で捉えることが行われてきましたし、一切の可変状態を持たない関数型オブジェクト指向プログラミング言語というのもあります。オブジェクト指向と関数型プログラミングの融合という思想はそのようなアカデミックな土壌の中から出てきたのでしょう。
Scalaを形作るもうひとつの要素は、実用的であることです。Scalaは、スケーラブルかつ、現実主義な言語として設計されました。
スケーラブルというのは、小さなプログラムから大きなプログラムまで同じ概念で記述できることをいい、現実主義というのはとどのつまり、原理原則から外れていても実用上それが必要ならば認めようという態度です。
そのため、Scalaには本来の設計思想に必ずしも沿わなくても、それが実用的であれば受け入れられるという懐の広さがあります。関数型プログラミングを非常に重視しているのに可変コレクションや変更可能な変数が言語仕様や標準ライブラリにあるのも、そのような現実主義のあらわれでしょう。
Scalaの歴史 - バージョン2.7から現在まで
Scalaは、オブジェクト指向プログラミングと関数型プログラミングの融合という理想を掲げつつも、現実主義な言語であることに先ほど触れました。ここからはScalaの歴史をたどりつつ、バージョンアップに伴って追加された新機能や変更点を整理してみます。
歴史を追っていく過程で、実用性のために「あまり綺麗でない」機能や、Javaとの互換性を考慮した機能を取り込んだりすることがしばしばあることが理解でき、現実に対して適度に妥協しつつ進化していく言語であることがよく分かるのではないでしょうか。
ただし、Scala 2.7(2008年2月リリース)より前は、資料が比較的少なく、言語仕様についてきちんと調査することが難しいことや、Scalaがおそらく初めて実用に使われ始めたのが2.7であることなどを考慮して、バージョン2.7以降の歴史について主な新機能や変更点を追っていくことにします。
Scala 2.7 - 本格的に注目され始めたバージョン
Scala 2.7は、海外および日本で本格的に注目され始めた時期のバージョンということで重要な意味を持っています。Scala 2.7では、主にJavaのジェネリクスとの相互運用性を改善する変更が入りました。
Scala 2.7より前のバージョンでは、Javaのジェネリックなクラスは型パラメータが一切ない状態で見えていたため、例えばjava.util.ArrayList
を扱うコードは以下のようになっていました。
val alist = new java.util.ArrayList alist.add("Hoge") val hoge = alist.get(0).asInstanceOf[String]
せっかくJava 5以降、ジェネリクスによっていろいろな領域に型安全性がもたらされたのですが、ScalaからJavaのライブラリを利用する限り、バージョン2.7までその安全性を享受することはできませんでした。
Scala 2.7では、クラスファイル中のJavaジェネリクスに関する情報を読み取れるようになったため、それ以降は(現在でも)上記のコードを以下のように書くことができます。
val alist = new java.util.ArrayList[String] alist.add("Hoge") val hoge = alist.get(0)
Scalaは現在ですら、しばしばJavaのライブラリ資産に依存せざるを得ませんから、このようにJavaの資産との相互運用性を高める改善は、実用性のためには重要です。
また、Javaコードの中からScalaコードを呼び出すケースを適切に扱えるようにするために、Java側からもScalaジェネリクスの情報を読み取れるように、クラスファイルの情報の格納方法が変わりました。Scalaのジェネリックなコードをクラスファイルにコンパイルする際、Javaジェネリクスが使っている領域に、ジェネリクスに関する情報を格納するようにしたのです。
ただし、Scalaのジェネリクスの方がJavaのジェネリクスより高機能であり、Javaジェネリクスでは表現し切れない部分を含んでいるため、Scalaジェネリクスに関する情報も別途含むようになっています。
以下は、JavaからScalaのジェネリックなコレクションであるBuffer
を利用したコード例です{$annotation_1}。
import scala.collection.mutable.ArrayBuffer; ... ArrayBuffer<String> buffer = new ArrayBuffer<String>(); buffer.append("A"); buffer.append("B"); buffer.append("C");
Scala 2.7では、JavaとScalaが混在したプロジェクトでも適切にコンパイルが行えるように、処理系が改良されました。現在でも、アノテーションなどの一部機能はScalaでは書けず、Javaのコードを書く必要があるため、このような混在プロジェクトを扱えるように進化したのは、実用上とても重要です。
Scala 2.8 - コレクションライブラリを全面的に再設計
Scala 2.8では、新機能がいくつか追加されるとともに、コレクションライブラリの全面的な再設計が行われました。私見では、言語機能の追加よりコレクションライブラリの再設計の影響が大きかったのではないかと思います。
例えば、Scala 2.7までは、不変コレクションと可変コレクションはそれぞれ個別のクラスとして定義されていましたが、Scala 2.8ではscala.collection.immutable
(不変コレクション)とscala.collection.mutable
(可変コレクション)という形で明確にパッケージが切り分けられ、あるコレクションが不変か可変かがすぐに分かるようになりました。
また、継承階層も整理され、不変コレクションと可変コレクションそれぞれにルートクラスが用意され、さらにコレクション全体を統合するルートクラスが用意されるようになりました。これによって、コレクションライブラリを使ったプログラミングが非常に容易になりました。
配列の扱いも若干変わっています。2.8以前は配列はあくまで普通の可変コレクションという形で取り扱われていたのですが、それではJavaとの相互運用性に問題があるということで、Scalaの配列とJavaの配列が同じ表現になりました。
val x: Array[Int] = Array(1, 2, 3) // int[] x = new int[]{1, 2, 3} とほぼ同じ
Scalaの世界で完結するならそれまでのアプローチの方が美しかったですが、Scalaが実用性を重視していることが、このような変更からも見て取れます。
これまで要望されていた、名前付き引数とデフォルト引数の機能も追加されました。
名前付き引数は最近の言語ではおなじみになりましたが、メソッドを呼び出すときに仮引数名でどの実引数を与えるかを指定することができる機能です。例えば、以下のようなコードが記述できます。
val person = Person(name = "Kota Mizushima", age = 34)
名前付き引数をうまく使えば、コードの可読性を上げることができます。
デフォルト引数も最近の言語ではおなじみですが、引数のデフォルト値をメソッドの定義時に指定しておくことで、その引数が省略されたときにデフォルト値を補ってくれる機能です。
object Strings { def join(values: List[String], separator: String = ","): String = values.mkString(separator) }
上記のメソッドjoin
はvalues
の各要素をseparator
で挟んだ文字列を返すメソッドですが、separator: String = ","
という形でseparator
のデフォルト値として","
が指定されています。このようにすることで、以下のコードは
Strings.join(List("A", "B", "C"))
次のように解釈されます。
Strings.join(List("A", "B", "C"), ",")
デフォルト引数を活用することでメソッドをオーバーロードせずに、しばしば一つの定義に収めることができるようになります。また、特定の引数について、大半のユースケースでは引数の値が決まっている場合にも有効です。
Scala 2.7までは、トップレベルには次のいずれかしか書くことができませんでした。
- パッケージ宣言
- インポート宣言
- クラス宣言
- トレイト宣言
- オブジェクト宣言
そのため、ユーティリティメソッドはオブジェクト宣言の中に定義する必要がありました。
Scala 2.8では、パッケージオブジェクトという機能を導入することで、パッケージに直接ユーティリティ関数などを定義できるようになりました。パッケージオブジェクトを使ったコードは、例えば以下のようになります。
package com.github.kmizu.myproject package object commons { def foo(): String = "Foo" def bar(): String = "Bar" }
このコードを利用する側は以下のようになります。
import com.github.kmizu.myproject.commons._ foo() // "Foo" bar() // "Bar"
実態としては、パッケージに見えるオブジェクトを定義して、その下にユーティリティメソッドを定義しているのですが、パッケージ名とオブジェクト名をあわせることができるため、直接パッケージからユーティリティメソッドをインポートできるようになり、利便性が向上しました。
その他にもいくつか変更点があるのですが、それほど重要ではないので、Scala 2.8についてはここまでにしておきます。Scala 2.8は、新機能の導入によって実用性を高めるとともに、コレクションライブラリの再設計などによって標準ライブラリを綺麗にしようと試みるバージョンであったと言えます。
Scala 2.9 - やや早過ぎた並列コレクション
Scala 2.8では、標準ライブラリの根幹とも言えるコレクションに大改修が入ったほか、新機能もいくつか入ったためか、初期の処理系の品質が低く、当時のScalaコミュニティでは非常に批判されることになりました。
その結果として、Scalaのリリースマネジメントが改善され始めることになるのですが、それはともかく、Scala 2.8のような大改修を嫌ったのか、Scala 2.9ではあまり大規模な変更は入っていません。特に、言語としてはScala 2.8と(ほぼ)変わっていません。追加されたのは、並列コレクションとプロセスAPIというライブラリレベルでの追加です。
並列コレクションは、普通のコレクションに対してpar
メソッドを付加するだけで複数コアがあるマシンでは自動的に並列化されるという仕組みです。
例えば、以下は1から10000までのリストの全数値の合計値を計算する式ですが、コア数に応じてリスト中の要素を集計する処理が並列化されます。ユーザは、コレクションを.par
を用いて並列コレクションに一時的に変換することだけ考えれば良くて、並列化に関する細かいことを考えずに処理性能を上げられる可能性があるのが利点です。
val numbers = (1 to 10000).toList println(xs.par.fold(0)(_ + _))
並列コレクションが入ったのは、CPUの単一コアでのクロック向上限界に伴って、マルチコアが普及し始めたからなのですが、不幸にしてごく最近まで一般用途のCPUでは高くても4コア8スレッド止まりだったこともあってか、この機能は現在に至るまであまり生かされていません。
しかし、AMD Ryzenによって8コア16スレッドの一般用途のCPUが発売されたことにより、ここ1年で急速に一般用途向けのCPUのコア数は増大しつつあります。
また、エンスージアスト向けといって特にPCマニアをターゲットにしたCPUで、32コア64スレッドというすごいコア数のものをRyzen Threadripperが出してきたので、そういった事情を考えると、並列コレクションはやや「早過ぎた」機能だったかもしれません。
Scalaはもともと、スクリプティング言語のように他のプロセスを起動してその標準出力を受け取ってリダイレクトしたりパイプに流したりといったことがあまり得意ではありませんでしたが、Scala 2.9では、scala.sys.process
パッケージの導入によって、その状況が改善されました。
例えば、ls
コマンドの実行結果を文字列として受け取りたい場合、ただ単に以下のように記述すれば良いです{$annotation_2}。
import scala.sys.process._ val str = "ls" !!
Scala 2.10 - 言語機能を大幅に強化
Scala 2.10では、大幅な言語機能の強化が行われました。以下は、Scala 2.10で導入された新機能のリストです。
- Macro
- Implicit Class
- String Interpolation
これらの新機能の導入によって、今まで書くのに手間がかかっていたパターンを簡単に書けるようになりました。
同時に、言語機能のモジュール化が行われ、例えば後置演算(Postfix Operation)を記述するには、その機能を使う旨を以下のように明示的に宣言しないと警告が出るようになりました。
import language.postfixOps
実用でもScalaがある程度使われるなか、機能によっては、安易に使うと可読性を低下させたりといったデメリットを伴うことが明らかになったため、そういった機能を使うときには明示させるようにしようという意図があります。
ちなみに、上述の機能のうちMacroについては、以下のようにimport
しなければ使うことができません。これは、Macroがあくまで実験的(experimental)な機能であり、正式な機能ではないためです。
import language.experimental.macros
Scala 2.11 - 一部の標準ライブラリを分離
Scala 2.11では、言語機能の追加はほぼ行われず、コンパイラの安定化や、一部の標準ライブラリの分離が行われました。具体的には、
- パーザコンビネータ(
scala.util.parsing.combinator
)の分離 - XMLライブラリ(
scala.xml
)の分離
が行われました。標準ライブラリでこれらの機能があまり利用されていないと判断されたためです。
コンパイラの安定化については、リリースノートにどのような改善が加えられたか書かれています。
Scala 2.12 - Java 8との互換性を重視
Scala 2.12では、これまで追従してこなかった、Java 8との互換性に重点を置いた変更が行われました。
- トレイトをインタフェースとしてコンパイル
Java 8から、インタフェースがメソッドを持てるようになっため、トレイトをインタフェースとしてコンパイルするように改良されました。それまでは、トレイトはインタフェースを表すクラスファイルを実装を表すクラスファイルの2つのクラスファイルに分割されていたため、クラスファイルのサイズが小さくなりました。また、トレイトにメソッドを追加してもバイナリ互換性を崩すことがなくなりました。
- 関数からSingle Abstract Method(SAM)Typeへの変換をサポート
Java 8のラムダサポートは、Scalaのように関数の型を用意するのではなく、単一抽象メソッドを持った型(これをSAM Typeと言います)への変換として定義されることになりました。これは、言語仕様に新たな型を加えることなくラムダをサポートするための苦肉の策のように思えます。
しかし、SAM Typeへの変換としてラムダが実装された結果、標準ライブラリのjava.util.function.*
にある型などがScalaから簡単に利用できないことになってしまいました。このパッケージの型はJava 8のラムダを使う上で重要な標準的な型を定義しているので、これがJava 8のラムダと同じくらい簡単に書けないと利便性を損ねてしまいます。
そこで、Scala 2.12では、SAM Typeが要求されている箇所に(x, y) => x + y
のような無名関数があった場合、自動的にSAM Typeのインスタンスを生成してくれるようになりました。この変更によって言語仕様はやや複雑になりましたが、Java 8のラムダを活用したライブラリをScalaから利用するのが容易になりました。
Scala 2.13 - 未来に向けたリリース
Scala 2.13は、まだM5(マイルストーンビルド5)がリリースされた状態で(2018年8月現在)、機能のフリーズが行われたわけではありませんが、既に入っている変更からは、次のような点が読み取れます。
- コレクションライブラリを単純化
- よりユーザーフレンドリーに
Scala 2.8以来、コレクションライブラリには大規模な変更が行われてきませんでした。しかし、現在のコレクションライブラリは実装の再利用性を重視し過ぎるため、内部の可読性を損なっている部分があり、Scalaチームを苦しめていましたし、ユーザにとってもCanBuildFrom
のようなよくわからない型が見えており、使いづらい部分がありました。
Scala 2.13の新コレクションライブラリでは、実装をよりシンプルにし、メンテナンスしやすくできるようになっています。また、ユーザにとっても今までより使いやすいコレクションライブラリを目指しているようです。これまでのコレクションライブラリとは一部で互換性がありませんが、多くのコードは今まで通り動きます。
また、Scala 2.13では、いくつかの言語機能やライブラリを非推奨にしています。
例えば、procedural syntaxと呼ばれる機能が現状のScalaにはあります。これは、Unit
を返す関数を以下のように書くことができる機能です。
def hello() { println("Hello") }
しかし、この機能は誤って返り値をUnit
にするなど、弊害が大きいことが明らかになったため、これまでも使うべきでない機能とされてきました。また、Scala 3では、この機能は既に削除されています。そのためか、バージョン2.13でprocedural syntaxを使うと、警告が出るようになりました。
他にも、これまでの進化の過程で、誤った設計上の決定が下されたライブラリがありましたが、それらについてもいくつかが非推奨になっています。
Scala 2.13はこれまでの仕様をある程度維持しつつ、これまで溜まっていたある意味、膿のような部分を出そうとするリリースだと言えそうです。
Scalaの進化 - まとめ
Scalaの進化について見てきましたが、おそらく想像以上に実用重視の機能が導入されていることに気付かれたのではないかと思います。Scalaはその設計思想もありますが、既に世界中の製品で使われていることもあり、常に実用性を意識して進化しています。
一方で、Scala 2.8でのコレクションライブラリの再設計に見られるように、言語(標準ライブラリ)を綺麗に保とうとする努力も見られます。Scalaは、実用性と仕様の綺麗さのバランスを取ろうと試行錯誤しながら進化してきたと言えるのではないでしょうか。
Scalaらしいコードとは
Scalaらしいコードとは何でしょうか? この問いに答えるのは、やや難しいことです。これは、Scalaがオブジェクト指向プログラミングと関数型プログラミングを融合した言語であり、どの程度「オブジェクト指向っぽく」書くかなどについて各人の見解が異なり、また、製作者が意図した設計思想がしばしば誤解されていることによるからではないでしょうか。
現実のScalaのOSSライブラリを見ても、オブジェクト指向を重視する人と関数型を重視する人とでは、コードの書き方がけっこう異なってきます。とはいえ、みんながバラバラなコードを書いているわけではなく、ある種の指針はあると信じています。
この節では、Scalaらしいコードについて、私の見解を書いてみたいと思います。改めて書きますが、この話題についてScalaコミュニティで統一した見解があるわけではないので、あくまで参考意見として捉えてください。
オブジェクト指向的なコードと関数型的なコード
「言語設計の思想に見られるScalaらしさ」の節で、Scalaの思想は、オブジェクト指向プログラミングと関数型プログラミングの融合に基づいているといったことを書きました。Scalaらしいコードについて考えるときには、この設計思想を踏まえることが重要です。
まず、オブジェクト指向的なコードとは一体何でしょうか? 「オブジェクト指向的である」とはどういうことかを問うと、各人の考えるオブジェクト指向を語り出して終わらない議論になることから分かるように、そもそも統一見解を見出すことは困難ですが、ひとまずJavaの世界でオブジェクト指向らしいと認められそうなコードについて考えてみます。
下記のクラスBufferStack
は、変更可能なスタックのクラスを表現しており、内部表現としてのBuffer
、つまり任意の添字の要素を変更可能という部分は隠蔽して、以下のような操作(トレイトMutableStack
で定義)のみによってスタックを扱えるようにしています。
-
push
: 要素を1つスタックにプッシュする -
pop
: 要素を1つスタックからポップする -
top
: スタックトップの要素を1つ取り出す。この操作はスタックを変更しない -
isEmpty
: スタックが空なら真を返す。この操作はスタックを変更しない
このように、データと操作の集合をひとまとめにして、インタフェース(Scalaの場合はトレイト)に定義されたメソッドのみを通じてオブジェクトにアクセスできるようにするプログラミングスタイルが、Javaの世界ではオブジェクト指向らしいコードであると認められるのではないでしょうか{$annotation_3}。
import scala.collection.mutable.Buffer trait MutableStack[T] { def push(element: T): Unit def pop(): Unit def top: T def isEmpty: Boolean } class BufferStack[T] extends AnyRef with MutableStack[T] { private[this] val elements = Buffer.empty[T] override def push(element: T): Unit = elements += element override def pop(): Unit = elements.remove(elements.length - 1, 1) override def top: T = elements.last override def isEmpty: Boolean = elements.isEmpty } ... val stack: MutableStack[String] = new BufferStack[String] stack.push("A") stack.push("B") assert(stack.top == "B") stack.pop() assert(stack.top == "A")
一方、関数型的なコードというのは、より明確です。関数型プログラミングは副作用を(極力)使わず、式の評価や不変データ構造の変換を通じてプログラムを表現するものですから、より副作用が少ないような形で書かれているプログラムが関数型的なコードであると言えます。
副作用を少なくするといったときに、HaskellのいわゆるIOモナドなどを用いてプログラムから副作用を隔離するアプローチもあれば、OCamlのように副作用自体の利用には制限を用いずプログラマに委ねるアプローチもありますが、可能な限り副作用を用いずにプログラムを記述するという方針は共通しています。
オブジェクト指向と関数型は直交している
これも一度簡単に述べたことですが、例を交えて改めて述べます。先程、オブジェクト指向プログラミングとはデータと操作の集合をひとまとめにして、操作の集合を通じてのみデータにアクセスできるようにする手法であるといったことを書きました。
このとき、典型的なオブジェクト指向プログラミングでは、操作を通じてデータを直接変更する、つまり副作用を利用するために、副作用を極力用いない関数型プログラミングと相反するものだと考えられがちですが、では、以下のようなクラス設計は「オブジェクト指向的ではない」でしょうか?
class BigInt(...) { def +(that: BigInt): BigInt = ??? def -(that: BigInt): BigInt = ??? def *(that: BigInt): BigInt = ??? def /(that: BigInt): BigInt = ??? override def toString: String = ??? }
上記のクラスBigInt
は固定長のサイズ、例えば32ビットや64ビットに収まらない整数を表現するためのクラスの骨組みを表現したものです。通常、BigInt
の内部表現(例えば32ビット整数の配列として表現するかバイト列で表現するかといったこと)は隠蔽され、+などの演算を通じてのみ間接的にデータにアクセスできるので、オブジェクト指向的であると言えるでしょう(JavaのBigInteger
やBigDecimal
も同じように設計されています)。
また、このようなクラスでは、演算を行った結果、副作用を起こすのではなく、変更されたデータを新しく返すため、関数型的であるとも言えます。これが、オブジェクト指向と関数型は直交しているということの意味です。
現実には、オブジェクト指向アプローチでは副作用を用いることが多いでしょうが、それはパラダイムとして必須な要素ではありません。したがって、オブジェクト指向と関数型が相反するということはないのです。
オブジェクト指向を捨てずに関数型を取り入れる
繰り返しますが、先に述べた例を見ると、オブジェクト指向の考え方(データと操作をひとまとめにする)と関数型の考え方(可能な限りデータを不変にし、副作用を排除する)は相反するわけではないことが分かります。この考え方を一般化すると、
- データと操作はひとまとめにする(オブジェクト指向的アプローチ)
- データは不変にし、操作は新しいオブジェクトを返すようにする(関数型アプローチ)
という考え方にたどり着きます。このような考え方に基づいてプログラムを書いていくことで、自然にオブジェクト指向と関数型は共存できるようになるはずです。
ただ、関数型的なアプローチは、JVMのようなVMの上ではしばしば効率が悪いので、「手続き的な」オブジェクト指向が必要になることもあるでしょう。それは本来あるべき姿からすると妥協といえますが、原理主義に陥って現実的な妥協を避けるのは良くないことです。
Scalaを使う上で注意すること
Scalaに限らず、他のプログラミング言語でもそれぞれ言語特有の注意事項があります。この節では、特にScalaを使う上で注意するべきことを説明したいと思います。Scalaには特に
- メジャーバージョン間でバイナリ互換性がないこと
- 歴史的事情で多様な記法が許されていること
などに起因した問題があります。
バイナリ互換性の問題
Scalaでは、バージョン2.10より前の時代には、バージョンアップのたびに、生成されたバイナリ(.class)の互換性がなくなっていました。これは、Scalaのライブラリを配布する上で非常に困るため、Scala 2.10になってから、マイナーバージョン(Scala 2.10.XやScala 2.11.XのXの部分)が変化しても、バイナリの互換性が保たれるようになりました。
一方で、依然としてメジャーバージョン(Scala 2.YのYの部分)が上がった場合にはバイナリ互換性が保証されないため、新しいメジャーバージョンのScalaがリリースされるたびに、既存ライブラリを新しいScalaに向けてビルドし直す必要があります。後述するsbtのクロスビルド機能によって、複数のメジャーバージョン用に複数のバイナリを生成することが比較的容易であるため、このことは必ずしも問題にはなりません{$annotation_4}。
x -> y -> z -> Scala
のように、多段階の依存性を持ったライブラリx
は、新しいメジャーバージョンのScalaがリリースされてしばらくたつ(経験的には1カ月程度かかります)までリリースされないことがよくあります。Scalaを使う上では、このようなバイナリ互換性に関する問題があることを知っておく必要があります。
Dotty(Scala 3)では、Scalaの抽象構文木をTASTYというシリアライズされた形式で保存することで、ライブラリのバージョン間互換性問題を解決しようとしています。
依存ライブラリをよく吟味する
Scalaには、前述のバイナリ互換性問題があります。この問題はsbtのクロスビルド機能によってある程度緩和されていますが、クロスビルドを設定していなかったライブラリに依存していた場合、Scalaのメジャーバージョンが上がったときに、そのライブラリを利用できなくなるリスクがあります。
Scalaのライブラリは基本的にOSSなので、フォークして利用するという手段はありますが、自らそのライブラリをメンテナンスするコストが発生することがあります。ですから、どのようなライブラリに依存するかを吟味することは、特にScalaでは重要です。
どのようなライブラリなら安心して利用できるかを一概に言うことは難しいのですが、
- ライブラリがクロスビルドされているか(複数のScalaバージョン向けにビルドされているか)
- 継続的にメンテナンスされているか(例えば、2年以上更新がないライブラリに依存するのはリスクがある)
- GitHubである程度のスターを獲得しているか(人気があるライブラリは、たとえ一時的にメンテナンスされなくなっても、後継のメンテナが現れることもある)
といったことが基準になるのではないかと思います。
また、ScalaTestなどの「準標準ライブラリ」とも言えるライブラリは、Scalaチームも継続してビルドできることを確認する傾向がありますから、そういったライブラリには安心して依存してかまいません。
ScalaTestかSpecs 2か
Scalaには、主に単体テストを行うライブラリとして、ScalaTestと、Specs 2があります。どちらも機能的には大きな差がありませんが、ScalaTestがさまざまなスタイルのテストをサポートするのに対して、Specs 2はBDD(Behavior Driven Development)アプローチを主にサポートするという違いがあります。
数年前は両者が拮抗していたように思いますが、最近はScalaTestの方が優勢なようです。また、ScalaTestではJavaなどの言語で主に用いられるアサーションベースのテストをサポートしているので、迷ったらScalaTestを利用すればとりあえず間違いがないと言えます。
sbtとsbtプラグインを活用する
Scalaでは、開発元のLightbend社が提供しているビルドツールsbtが、標準的な存在となっています。Javaやその他のJVM言語用のビルドツールであるGradleやMavenを利用することも可能ですが、ほとんどのScalaプロジェクトがsbtを利用しているので、sbtを利用してプロジェクト定義やビルド設定を記述するのが無難でしょう。
また、sbtはプラグインという仕組みを用意しており、既存のsbtプラグインも豊富です。sbtプラグインを用いることによって、
- ライブラリリのMaven Centralへのpublishを自動化
- プロジェクトサイトの構築
- 全部入り実行可能jarの生成
- JMHによるベンチマークの実行
といったさまざまな作業を簡単にできるようになります。sbtプラグインは複数の方法で配布されていますが、主なプラグインはCommunity Pluginsのページに掲載されているので、最初はそれを見て適切なプラグインを選ぶのが良いでしょう。
sbtプラグインでできることのほとんどは、sbtの設定ファイルを記述することでも行うことができますが、しばしば膨大な記述が必要になるので、可能ならプラグインに頼りましょう。
スタイルを統一する
プログラムを書く上で、スタイルを統一することはScalaに限らず重要ですが、Scalaの場合、同じことをするのに「ちょっと違った」書き方をすることが許されていることが多いので、注意する必要があります。
なお、同じことをするのに複数の書き方があることを問題であるとする人もいますが、そもそもプログラミング言語は同じ問題を解くのに複数通りのプログラムの書き方を許すものなので、マクロ的にはそういう言い方には疑問があります。
Scalaが同じことをするのに「ちょっと違った」書き方をするのが許されている例としては、括弧の省略があります。次のプログラムの(1)と(2)はどちらも許され、全く同じ動作をします。
class Hoge { def display(): Unit = println("Hoge") } new Hoge.display() // (1) new Hoge.display // (2)
このようなことが許されているのは主に歴史的事情によってなのですが、それはともかく実際のプロジェクトでは括弧の省略に関する指針を決めておく必要があります。
幸い、この点に関してScalaコミュニティの方針は一貫していて、定義側であっても利用側であっても、そのメソッドに副作用があれば括弧を付ける、なければ括弧を付けないというものです。先程のプログラムだと、(1)が良くて(2)が駄目ということです。
別の例を挙げます。Scalaでは無名関数、特に複数の式が並ぶ無名関数を書くときに大きく分けて2通りの記法があります。1つは、以下のように最初から複数の式を並べられる無名関数を記述する方法です。
List(1, 2, 3).foreach{x => val y = x * x println(y) }
もうひとつは、単一の式を書く無名関数の本体に、ブロック式{$annotation_5}を書く方法です。具体的には以下のようになります。
List(1, 2, 3).foreach(x => { val y = x * x println(y) })
この書き方は最初の書き方に比べて冗長なだけで利点があまりないため、積極的に採用する価値はありません。最初、本体が単一の式だったところに式を追加したいときに、このような書き方をしたい誘惑にとらわれることはあるかもしれません。そういう場合でも、そのような書き方を許さないことをプロジェクトで決めておき、コーディングレビューで弾くようにしましょう。
このようにスタイルを統一することは、ミクロ的にはそれほど重要ではないように思えるかもしれませんが、全体的にコードを読むときの思考力を節約できますし、読むときのストレスを下げることにもつながります。
Scalaはしばしば複数のスタイルを許すことが問題であると言われますが、私見ではきちんと規約でスタイルを統一できればさほど問題にはなりません。スタイルを統一できない場合は、そもそもScalaでなく他の言語であっても問題になるでしょう。
まとめ
この記事では、「Scalaの設計思想とは?」という問いに答えるために、Scalaの誕生前の話や、Scalaの進化、オブジェクト指向プログラミングや関数型プログラミングの定義について考察しました。
特に重要であり、再度強調しておきたいのは、オブジェクト指向プログラミングと関数型プログラミングは直交する概念である(少なくともそうみなせる)ということです。少なくともScalaはそういう想定の元に設計された言語ですので、両者を対立したものとして捉えてしまうと、Scalaを十分に活用できないように思います。
また、私が考えるScalaらしいコードについてや、Scalaを書く上で注意すべきことについて、何点か例を挙げて説明しました。ここで述べた点は、どんなScalaプログラムを書くときでも考慮すべき話ですので、読者の皆さんにはそういった点に注意しつつ、Scalaプログラミングを楽しんでいただけたらと思います。
【修正履歴】初出時に、Scala 2.8における可変コレクションのパッケージの表記に誤りがありました。ご指摘により修正しました。(2018年9月11日19時11分)