はじめに

こんにちは、Rerurate_514と申します。
今回はenumじゃなくてsealedクラスを使おうの会と題しまして、sealedクラスを普及させていこうと思います。
おそらくそんなに聞きなじみがない言葉かと思います。ただ個人的は列挙型より使いやすいかなと感じたので書いていこうと思います。
東北工業大学アドベントカレンダー22日目の記事になります。もともとは先輩が記事の予定を入れていたのですが、さっき確認したら空いていたのでちょうど書こうと思っていた記事を出そうと思います。

そもそもEnum(列挙型)とは?

では本題に入る前に、enumと呼ばれている列挙型についてちょっとだけ説明しようと思います。

皆さんアプリやプログラムを作成する際に、画面やインスタンスが状態を持つことがあると思います。
例えば以下の例。

stateDiagram-v2
    [*] --> Idle
    
    Idle --> Loading : データ取得イベント
    
    state Loading {
        [*] --> Fetching
    }
    
    Loading --> Success : 取得成功
    Loading --> Error : 取得失敗
    
    Success --> Idle : 再取得 / 画面更新
    Error --> Loading : リトライ
    Error --> Idle : キャンセル

これを一画面の状態遷移オートマトンと考えると、少なくともこの画面はIdleLoadingSuccessErrorという4つの状態を持つことになります。
この時画面はどのようにして状態を持つべきでしょうか?
一番簡単なのはこのようにプリミティブ型を使用したものであります。

state = "Idle"

ただこの場合だと、状態ごとに処理を変えたい場合など以下のように書かなくてはならなくなります。

state = "Idle"
 
if(state = "Success") {
	//データ取得成功時の処理 - データを表示するなど
} 
else if(state = "Error") {
	//データ取得失敗時の処理 - リトライを促すなど
}
//etc...
 
//または
switch(state) {
	case "Success": { ... }
	case "Error": { ... }
}

これはとても保守性が低い状態です。詳しくはプリミティブを使用しないプログラミングにて、文字列などのプリミティブ型で管理をすることがいかにダメかを説明しています。

これを解決するのが列挙型(enum)になります。
上記の状態遷移オートマトンをenumで表現してみます。

enum ScreenState {
	Idle,
	Loading,
	Success,
	Error
}

このようになります。まずメリットとして、一覧でどんな状態があるかを確認できます。この時点でプリミティブ型を使用するより、可読性がぐんとあがっています。
これはこのように使用することができます。

state = ScreenState.Idle;

アクセスする際もIDE上でenumで定義されているメンバを予測変換で出してくれるので文字列のタイポなどがなくなる点も安心です。

そしてこれを条件分岐するなら、

state = ScreenState.Idle;
 
switch(state){
	case ScreenState.Idle: { ... }
	case ScreenState.Loading: { ... }
	case ScreenState.Success: { ... }
	case ScreenState.Error: { ... }
}

となります。
この時、switchに列挙型の変数を定義すると、その条件分岐がすべての列挙されている項目を網羅しない限りIDEが警告を出してくれます。(あくまで Dart の場合)

これが列挙型、enumの機能になります。
ただしデータ構造などを持つことは難しいです。
例えば Kotlin の場合

enum class GameMode(val level: Int) {
    EASY(1),
    NORMAL(2),
    HARD(3)
}

それぞれの項目でデータ構造は単一です。
ただこれが、別々の項目で別々のデータ構造を持ちたい場合、例えば以下の例。

Successならdata:stringを持ちたい
ErrorならerrorCode:Intとmessage:Stringを持ちたい

これをenumで強引に書くなら、

enum class ScreenState(
    val data: String? = null,
    val code: Int? = null,
    val message: String? = null
) {
	IDLE,
    SUCCESS(data = "Success Data"),
    ERROR(code = 404, message = "Not Found"),
    LOADING
}

このような不自然でとても最悪な構造になってしまいます。

別の例、 Dart だと、

enum GameMode {
	easy,
	normal,
	hard
}
 
extension GameModeEx on GameMode {
	int get level { 
		switch (this) { 
			case GameMode.easy: return 1; 
			case GameMode.normal: return 2; 
			case GameMode.hard: return 3; 
		} 
	}
}

拡張関数を使わないと、enumがデータを持つことはできません。
この場合だとデータを持つというより、データによって返す値が変わるgetterを定義しているだけなので、厳密には持っていません。

今は DartKotlin と同じような書き方ができるみたいです。知らなった;;

enum GameMode {
  easy(1),
  normal(2),
  hard(3);
 
  const GameMode(this.level);
  final int level;
}

別々の項目で別々のデータ構造を持ちたい場合は前述の拡張関数を書くことになります。

このように列挙したそれぞれの項目に応じて返す値を変える場合、とても面倒くさいです。

ではsealedクラスとは?

ではenumの基本的な機能を覚えたところで、sealedクラスの話になります。
sealedクラスを検索してみるとこんな文言が目に入ります。

特定のクラスやインターフェースだけが継承できるように制限をかけるためのクラス

これは確かにそういった機能を持ちます。というより、これが目的で開発された修飾子です。
簡単に言うと、sealedがついたクラスを継承するときは、同一ファイル内のみで継承できると考えてください。(ほとんど一般的な場合)
(Kotlin 1.5以降だと同一パッケージ内、dartなら同一ファイル内)protectedは可視性に関する修飾子なので、sealedと関係はありません。
これは外部のモジュールなどが、みだりにサブクラスを増やすことを禁止します。これにより、条件分岐がその網羅性を保証することができます。

ではsealedクラスの例を見てみます。(Dart)

sealed class ScreenState {};
class Idle extends ScreenState {};
class Loading extends ScreenState {};
class Success extends ScreenState {};
class Error extends ScreenState {};

ここまではenumと使い方は変わりません。
条件分岐も同様に使用することができます。

state = Idle();
switch(state){
	case Idle(): { ... }
	case Loading(): { ... }
	case Success(): { ... }
	case Error(): { ... }
}

sealedクラスは基本的にクラスと使い方は同様なので、インスタンス化を使用します。
この時、型評価にisなどは書かなくてもう良いです。sealedクラスをそのような使い方をするのが、言語側でわかっているからです。
ちなみにこの型判定方式を「パターンマッチング」といいます。

余談ですが、このようにif条件で型が確定している場合、スコープ内でその型を使用するようにするというのも「パターンマッチング」といいます。

//適用
if (o is int i) {...}
 
//適用前
if (o is int) {var i = (int)o; ... } //ここで条件でintが確定しているのにキャストが発生している。

複雑なデータ構造を持つことができる点

sealedクラスがenumと決定的に違う点、それは、「複雑なデータ構造を持つことができる点」になります。
例えば以下の例。

sealed class ScreenState {}
 
class Idle extends ScreenState {}
 
class Success extends ScreenState {
  final String data;
  Success({this.data = "Success Data"});
}
 
class Error extends ScreenState {
  final int code;
  final String message;
  Error({this.code = 404, this.message = "Not Found"});
}
 
class Loading extends ScreenState {}

この例では、それぞれの項目が別々のプロパティを持つことができます。
これはenumではできないことです。

またこれはちょっと実用的なユースケースが思いつかないですが、sealedクラスで作成した列挙型のプロパティには自身の型を再帰的に含めることができます。
正直使いどころさんは分かりません。

ではsealedが推奨されない場合とは

ただ、みだりにenumsealedに置換するのはちょっと違います。
これは設計段階やアーキテクチャ上、プロパティを持たないことが確定していたり、持つことがアンチパターンであったりする場合です。
例えば以下の例。

enum MusicMode {
	normal,
	loop,
	shuffle
}

このenumはプロパティを持たなくとも良いです。
なぜなら、ただモードをしているだけ、要するに状態を伝えるためだけに存在しているからです。
このenumを使用するユースケースは、

void setMusicMode() { ... }
MusicMode get currentMusicMode() { ... }
void handleMusicCompletion() {
	switch(currentMusicMode) {
		case MusicMode.normal: { ... }
		case MusicMode.loop: { ... }
		case MusicMode.shuffle: { ... }
	}
}

ぐらいでしょうか。
この構成の場合、プロパティを持つ意味がなさそうなので、持たないほうがいいです。
さらにいうと、私は不変主義者なので、状態が変数によって変わるっていうのもちょっと設計的にどうなのかなって思います。

ただ、

ただこのsealedクラス、enumと違って、ぱっと見でどんなクラス群に属しているかがわかりずらいです。
例えば以下の例。

swtich(state) {
	case Normal(): { ... }
	case Loop(): { ... }
}

これだけ見ると、Normalがどんなクラスかわかりません。
もっと例を見てみます。

setMusicListener(listener, Normal())

このような関数の例、Normalが何のクラスかがぱっと見でわかりません。
これをenumで書き直してみると、

setMusicListener(listener, MusicMode.normal)

といった具合に、引数に渡しているのがそれぞれ何なのかが明確に分かります。
個人的に、実装先を見ないとコードの意図がわからない実装は避けたいので、こういった場面ではenumの方がいいです。

おわりに

ここまでいろいろ書きましたが、ぶっちゃけenumの方が便利だと思います。
最後に話した、コードの意図が見えないのがきつすぎて私には使いこなせませんでした。