Scala 3.0/Dotty enumの紹介 (後半) Scalaにおけるenumeration/列挙体の歴史とScala 3.0 enum導入の背景

マーベリック株式会社、技術広報のリチャード 伊真岡です。前回に引き続きScala 3.0の主要機能の一つenumについて紹介します。前回はScala特有の事情ではなく、プログラミング言語を問わず、広く一般にenumeration/列挙体と呼ばれる機能がどんな場面で役に立つのかを紹介しました。

techlog.mvrck.co.jp

今回はScalaにおけるenumeration/列挙体の歴史とその前提となるCやJavaからの流れ、そしてScala 3.0であらたにenumが新機能として導入される背景を紹介します。

C言語の列挙体

まずはCでの列挙体について少し紹介します。以下はint型引数とSomeEnum型引数を受け取る関数を定義して、コンパイラがエラーを吐くかを試したものです。

#include <stdio.h>
#include <stdlib.h>

enum SomeEnum {
    ZERO, ONE, TWO
};

void printInt(int i) {
    printf("print int! %d\n", i);
} 

void printEnum(SomeEnum e) {
    printf("print enum! %d\n", e);
} 

int main() {
    printInt(0);    
    printInt(ZERO); 

    // compile error: invalid conversion from 'int' to 'SomeEnum'  
    // printEnum(1); 


    printEnum(ONE);
    return 0;
}

printInt(ZERO);とint引数が期待される箇所に、異なる型であるSomeEnum型の変数を渡すことができてしまいます。

Cのenumの使い方に関しては、int値のラベル付けとしての意味合いが強いと言えます。例えばswitchの条件分岐を記述する際にコードを読みやすくするために使えます。

#include <stdio.h>
#include <stdlib.h>

enum SomeEnum {
    ZERO, ONE, TWO
};

int main() {
    int i;
    int check = scanf("%d", &i);
    
    if(check != 1) {
        return 1; //error reading from console
    } else {
        switch(i) {
            case ZERO :
            printf("You entered ZERO = %d", i);
            break; 
            
            case ONE :
            printf("You entered ONE = %d", i);
            break; 
    
            case TWO :
            printf("You entered TWO = %d", i);
            break; 
            
            default :
            printf("You entered something else = %d", i);
        }
        
        return 0;
    }
}

つまりCのenumは前回の記事でも紹介した「コード中で可能な値を現実世界での正当な値に完全に一致させる」という意味合いは弱かったようです。このアイデアはElmのドキュメントに丁寧な説明がありますので再びリンクを貼ります。

guide.elm-lang.jp

Javaでのenum

次にJavaのEnumerationを振り返ります。JavaにもC同様enumというキーワードがあり、以下のコードでDayという型を定義できます。ちなみにJavaでは定数は大文字にする慣例があるので以下の曜日名はすべて大文字を使いました。

public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

C同様switch文で使うことも出来ます。

class Main {
    public enum Day {
        MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
    }

    public static void main(String[ ] args) {
        Day today = Day.THURSDAY;
        switch (today) {
            case MONDAY   :
            case TUESDAY  :
            case WEDNESDAY:
            case THURSDAY :
                System.out.println("Low energy");
                break;
            case FRIDAY :
                System.out.println("Happiest day of the week?");
                break;
            case SATURDAY:
                System.out.println("The world is yours");
                break;
            case SUNDAY:
                System.out.println("Time ticking to the hell");
                break;
        }
    }
}

また.values()というstaticメソッドが自動で付与され、定義したenumの値全てを取得できます。

for(Day d: Day.values()) {
    System.out.println(d);
}

さらにvalueOf()というメソッドで文字列から定義したenum型のインスタンスへの変換も出来ます。

Day d1 = Day.valueOf("MONDAY");
Day d2 = Day.valueOf("Monday"); //IllegalArgumentException 大文字と小文字は区別される

Javaのenumは内部ではjava.lang.Enumというクラスを継承しており、values()valueOf()といったメソッドもこのjava.lang.Enumから引き継いだものです。通常のクラスと同様、メンバやメソッドを持つクラスとして扱うことも可能です。Comparableインターフェースを実装しているので下記のようにcompareTo()メソッドで順番の比較ができます。

//1日差、compareToの左(receiver)が小さい
Day.MONDAY.compareTo(Day.TUESDAY);     //戻り値-1
Day.TUESDAY.compareTo(Day.WEDNESDAY);  //戻り値-1 
Day.WEDNESDAY.compareTo(Day.THURSDAY); //戻り値-1 
//複数日差、compareToの左(receiver)が小さい
Day.MONDAY.compareTo(Day.THURSDAY);    //戻り値-3 
Day.MONDAY.compareTo(Day.SUNDAY);      //戻り値-6 
//compareToの右(引数)が小さい
Day.TUESDAY.compareTo(Day.MONDAY);     //戻り値1
Day.SUNDAY.compareTo(Day.WEDNESDAY);   //戻り値4 

短いシンタックスenumeration/列挙体を定義でき、C言語のenumと比べても高機能なJavaのenumですが、Scala視点から見るとAlgebraic Data Typesの表現が出来ないことを物足りなく感じるかもしれません。そこを踏まえた上でいよいよScalaのenumeration/列挙体の歴史を紹介していきます。

Scala 2.x系の場合

Scala 2.x系ではenumeration/列挙体相当の機能を実現する選択肢が複数存在し、enumというそのものズバリなキーワードはScala 3.0/Dottyになるまで採用されていません。Scalaの思想としてlanguage constructが少ないことを重視したため、enumというキーワードを言語に追加することにはScalaの作成者であるOdersky教授は慎重でした。

ここからはScala 2系までのenumeration/列挙体相当の機能の実現方法を紹介します。まず1つ目はcase class/case objectを使う方法です。おそらくこの手法が現在のScalaコミュニティでは最も広く利用されていると思います。

sealed abstract class Day
case object Monday extends Day
case object Tuesday extends Day
case object Wednesday extends Day
case object Thursday extends Day
case object Friday extends Day
case object Saturday extends Day
case object Sunday extends Day

Algebraic Data Typesを表現することも出来ます。

//実際のScala 2.13 Option定義を簡略化したものです
sealed abstract class Option[+A]
final case class Some[+A](x: A) extends Option[A]
case object None extends Option[Nothing]

この手法はやや記述量が多く、例えばHaskellで同等のenumeration/列挙体やAlgebraic Data Typesを実現する場合と比べるとその差がわかります。

data DayOfWeek
    = Sunday
    | Monday
    | Tuesday
    | Wednesday
    | Thursday
    | Friday
    | Saturday
data Maybe a = Just a | Nothing

case class/objectの値が多数にのぼる場合は無視できない記述量の多さになってしまいます。しかし、必ずしも可読性が低いとは言えません。Scalaコードに目が慣れたプログラマはcase class/objectを見ると読解しやすいと感じるでしょう。

またAlgebraic Data Typesとして利用すると、以下のように「子」型になってしまいます。Scalaの標準ライブラリは可能な限り具体的な型を返却するように実装されていますが、Algebraic Data Typesとして使う場合は型推論を混乱させる場合があり不便です。

val opt1 = Some("str") //Some[String]型

これを避けるために明示的な型指定をしないといけません。

val opt2: Option[String] = Some("str") //Option[String]型

またJavaのEnumerationと比べて、valuesメソッドによって全ての値を取得することも出来ませんし、valueOf()メソッドでStringからインスタンスへの解決を行うことも出来ません。値どうしの順番づけもないので「MondayはTuesdayの前」といった表現をするには、自分でComparator等を実装する必要があります。

2つ目の手法はscala.Enumerationです。

object Day extends Enumeration {
  val Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday = Value
}

case class/case objectを使ったパターンと比べるとデメリットがいくつかあるため、Scalaプログラマの中には「scala.Enumerationを使うな!」という主張を行う人々もいます。しかし、後述のようにメリットも有るため適材適所で活かす余地のある手法です。

利点はJava enumと共通する部分が多くあります。少ない記述量でenumeration/列挙体を表現できる、valuesメソッドですべての値を取得、witName(s: String)メソッドでStringからの解決、順序定義が自動的になされる、といった点が挙げられます。これらはcase class/objectにはない優れた特徴です。

欠点のひとつ目は、Erasureの後はextends Enumerationしたクラスは同じ方になってしまうことです。これは以下のメソッドオーバーロードに関するエラーを見ていただければわかりやすいでしょう。

object Colours extends Enumeration {
  val Red, Amber, Green = Value
}

object Day extends Enumeration {
  val Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday = Value
}

object Functions {
  def f(x: Colours.Value)  = "That's a colour"
  def f(x: WeekDays.Value) = "That's a weekday"
}
double definition:
def f(x: Playground.this.Colours.Value): String at line 12 and
def f(x: Playground.this.WeekDays.Value): String at line 13
have same type after erasure: (x: Enumeration#Value)String

もう1つの欠点はパターンマッチでexhausitiveness checkが働かないことです。以下のコードはパターンマッチのcaseを網羅していませんが、コンパイラは警告を出しません。

object Day extends Enumeration {
  val Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday = Value
}

def g(day: Day.Value) = day match {
  //コンパイラはexhaustiveness check警告を出さない
  case Day.Monday => println("Mon!")
  case Day.Tuesday => println("Tue!")
}

//scala.MatchErrorが投げられる
g(Day.Thursday)

3つ目の方法はJavaのenumを利用する方法で、Scalaプログラムの中に.javaファイルを置いてそれを.scalaファイルからimportします。JavaとScalaを共存させるプロジェクトで、かつenumeration/列挙体をJava側で定義する必要があればこの選択肢を選ぶことになります。利点はJavaのenumそのままです。欠点はScalaプログラムの中で使う場合パターンマッチでexhaustiveness checkが効かない、Algebraic Data Typesとして使えないなどがあげられます。

4つ目としてenumeratumのような外部ライブラリを使う方法があります。enumeratumの機能について詳しくは言及しませんが、enumeration/列挙体のためにライブラリを追加するとなると、ソースコードの各所で使われる大きな依存性になるでしょう。慎重に検討した上で使うのが良いと思います。

Scala 3.0/Dotty enum

上記の選択肢の欠点を個別にみると、先ほど紹介したScalaのlanguage constructを少なく保つという思想のため、新しいenumキーワードを加えるほどの動機にはなりませんでした。しかしすべて合わせると十分な理由であるとOdersky教授は判断し、Scala 3.0でenumが導入されます。

https://github.com/lampepfl/dotty/issues/1970

In my personal opinion, when taken alone, neither of these criticisms is strong enough to warrant introducing a new language feature. But taking them together could shift the balance.

Scala 3.0 enumはcase class/objectとscala.Enumerationの利点両方をほとんど実現できます。よって今後はenumeration/列挙体の実現にはScala 3.0 enumが主流になっていくと予想されます。Odersky教授が「Scala 3.0は、よりOpinionatedになる」と述べていたように、Scala初心者にとってはデフォルトの選択肢があることは安心につながるでしょう。

https://www.scala-lang.org/blog/2018/04/19/scala-3.html

What’s new in Scala 3? become more opinionated by promoting programming idioms we found to work well

基本的なScala 3.0 enumの使い方については前回の記事をごらんください。ここでは前回の記事で紹介しなかったAlgebraic Data Typesとしての使い方を紹介します。case class/objectによる表現と比べ記述量が減りました。

enum Option[+T] {
  case Some(x: T)
  case None
}

また上記のようにOptionを定義すると、Some()の戻り値型がSome[String]ではなくOption[String]になるので、型推論を混乱させる可能性が減ります。

Option.Some("str") //Option[String]型

ここで注意点を一つ紹介します。下記のコードのようにScala 3.0 enumではcase 名前 {/*ボディ*/}の形でボディを定義できません。

enum Color {
  case Red { //これはエラー、caseはbodyを持てない
   //...
  }
}

正しくはこのようにenumのブロックの中にメソッドを定義する必要があり、caseを並べたパターンマッチ(下記の例では)をつかってenumの値ごとの処理を実装するように推奨されています。

enum Color {
  case Red, Green, Blue

  //こんなかんじ
  def doSomething(color: Color): Unit = {
    case Red => ...
    case Green => ...
    case Blue => ...
  }
}

この理由についてはコンパイラ側の都合でスコープの混乱を解決するためです。こちらのPull Requestで詳しく述べられています。 https://github.com/lampepfl/dotty/pull/4003

What's more, once we have eliminated case bodies we also have eliminated scope confusion. All that remains are the case parameters and extends clause. Arguably, if we choose an ADT decomposition of a problem it's good style to write all methods using pattern matching instead of overriding individual cases. So this removes an unnecessary choice.

まとめ

Scala 3.0で導入予定のenumについて2回に渡って紹介しました。とくにScala初心者にとっておすすめの機能として期待されているので、3.0がリリースされたら利用を検討してください。

参考文献