『タイプコードをクラスに変換する』について考えてみる👀
はじめに
リファクタリング技術を勉強していた際に標題の手法がテーマに挙がっていたので、
今回はこれを記事にしてみたいと思います。
タイプコードとは
タイプコードとはあるデータがどの様な状態を取り得るのかを表現したものになります。
ECサイトを例に挙げますと、ユーザーが注文している商品の状況が今どの様な状態にあるのか、というものを表現したものになります。
仮にその状態が、
- 保留中
- 処理中
- 発送済み
- 配達済み
上記4つで構成されると仮定しますと、これらをまとまりあるデータの集まりとして表現する方がメンテナンス性に寄与しますので、
このようなものは、
enum OrderStatus {
pending('保留中'),
processing('処理中'),
shipped('発送済み'),
delivered('配達済み');
final String description;
const OrderStatus(this.description);
}
上記のようにenumとその列挙子で表現いたします。
後述しますが、この表現方法により網羅性チェックという強味も発揮されますので、後述のパターンのような状況になければ、このリファクタリング手法は積極的に使用されるべきかと思われます。
リファクタリングパターンを適用
変換前
変換前のアプローチではenumの各列挙子にステータスを表す文字列を持たせています。
こうすることで列挙子と状態を一対に表現しているのですが、
個々の振る舞い(この例では「次のステータスに遷移する」)はswitch文で処理する必要があります。
/// 変換前
enum OrderStatus {
pending('保留中'),
processing('処理中'),
shipped('発送済み'),
delivered('配達済み');
final String description;
const OrderStatus(this.description);
}
// 振る舞いを実装する関数
OrderStatus getNextStatus(OrderStatus status) {
switch (status) {
case OrderStatus.pending:
return OrderStatus.processing;
case OrderStatus.processing:
return OrderStatus.shipped;
case OrderStatus.shipped:
return OrderStatus.delivered;
case OrderStatus.delivered:
return OrderStatus.delivered; // 最終ステータス
}
}
void main() {
var status = OrderStatus.pending;
print('現在のステータス: ${status.description}'); // 出力: 現在のステータス: 保留中
status = getNextStatus(status);
print('次のステータス: ${status.description}'); // 出力: 次のステータス: 処理中
}
一見すると特に問題がないように感じられますが、このコードには
新しいステータスを追加するたびにgetNextStatus
関数を変更する必要がある、
という問題点があります。
これはオープンクローズドの原則に反する可能性があるかと思われます。
ただこの書き方にもメリットがありますので、それは後述したいと思います。
変換後
上記の問題点を踏まえ以下のように書き換えてみます。
変換後のアプローチでは各ステータスをクラスとして定義しており、共通のインターフェースを実装することで代替しています。
これにより各クラスが
独自の「次のステータスを取得する」
と言うロジックを持つことができます。
/// 変換後
// 共通の振る舞いを定義するインターフェース
abstract interface class OrderStatus {
String get description;
OrderStatus getNextStatus();
}
// 各ステータスをクラスとして定義
class Pending implements OrderStatus {
String get description => '保留中';
OrderStatus getNextStatus() => Processing();
}
class Processing implements OrderStatus {
String get description => '処理中';
OrderStatus getNextStatus() => Shipped();
}
class Shipped implements OrderStatus {
String get description => '発送済み';
OrderStatus getNextStatus() => Delivered();
}
class Delivered implements OrderStatus {
String get description => '配達済み';
OrderStatus getNextStatus() => this; // 最終ステータスは自身を返す
}
void main() {
OrderStatus status = Pending();
print('現在のステータス: ${status.description}'); // 出力: 現在のステータス: 保留中
status = status.getNextStatus();
print('次のステータス: ${status.description}'); // 出力: 次のステータス: 処理中
}
このコードでは新しいステータスを追加する際は新しいクラスを作成するだけで済みます。
既存のクラスやロジックを変更する必要がないため、より拡張性が高く保守しやすい設計になるかと思われます。
enum + switchの網羅性チェックについて
網羅性チェックがある時
上記の代替案により個々のクラスで固有の振る舞いを表現することは出来るのですが、問題点もあります。
まずは以下のコードを見てください。
enum OrderStatus { pending, processing, shipped, delivered }
String getStatusDescription(OrderStatus status) {
switch (status) {
case OrderStatus.pending:
return '保留中';
case OrderStatus.processing:
return '処理中';
case OrderStatus.shipped:
return '発送済み';
// deliveredがないため、ここでコンパイラエラーが発生
// `The 'switch' statement is not exhaustive.`
}
}
このコードはenumをswitchの条件に与えて網羅性チェックを行なっています。
こうすることで条件分岐内での実装漏れをコンパイルエラーにより弾くことができるので、保守性が高まっているかと思われます。
網羅性チェックがない時...
一方で以下のコードを見てください。
abstract interface class OrderStatus {
String get description;
}
class Pending implements OrderStatus {
String get description => '保留中';
}
class Processing implements OrderStatus {
String get description => '処理中';
}
// 新しいステータスが追加されても…
class Canceled implements OrderStatus {
String get description => 'キャンセル済み';
}
// この関数は変更する必要がない
void printDescription(OrderStatus status) {
print(status.description);
}
void doSpecialAction(OrderStatus status) {
if (status is Pending) {
// 保留中の特別な処理
} else if (status is Processing) {
// 処理中の特別な処理
}
// Canceled が抜けていてもコンパイラはエラーを出さない
}
このコードだと先述のような網羅性チェックが効かず、実装漏れに気付きにくいと言う潜在的な欠陥が生まれてしまいます。
個別具体的な振る舞いが表現できるとしても網羅性チェックが利かなくなるのは中々考えものですね。。。
- 網羅性チェックが効きつつ
- 個々のクラスが特有の振る舞いが出来る
これを両立する方法はないものか。。。🤔
sealed classで代用してみる
上記のNeedsを上手く実現するために、今回はsealed修飾子を使ってみます💪
sealedクラスは自身のオブジェクト化は出来ませんが、以下のような網羅性チェックの機能を有しています。
よってこれを拡張するクラスを実装することで、個々のクラスをenumの列挙子のように表現することができます。
加えて個々のクラスは特有の振る舞いを表現することも出来るので、一挙両得なコードを表現することもできます。
ただし自身が宣言されたライブラリ以外ではその拡張を禁止する、というfinalクラスと同様の縛りがあるので、
この点に注意して同一ファイル内にコーティングする必要があります。
// 1. sealed classを定義
sealed class OrderStatus {
// 共通のプロパティやメソッドをここに定義
String get description;
}
// 2. sealed classを継承するサブクラスを定義
// これらのサブクラスは、他のファイルからは継承できない
class Pending extends OrderStatus {
String get description => '保留中';
}
class Processing extends OrderStatus {
String get description => '処理中';
}
class Shipped extends OrderStatus {
String get description => '発送済み';
}
class Delivered extends OrderStatus {
String get description => '配達済み';
}
// 3. switch文でパターンマッチングと網羅性チェックを活用
String getStatusDescription(OrderStatus status) {
return switch (status) {
Pending() => 'ご注文はまだ処理されていません。',
Processing() => 'ご注文を処理中です。',
Shipped() => 'ご注文は発送されました。',
Delivered() => 'ご注文は配達済みです。',
// Canceled() という新しいクラスを追加すると、
// ここで網羅性エラーが発生する!
};
}
// 4. クラスごとに異なる振る舞いを定義する
// この`upgradeStatus`は`switch`文を必要としない
extension on OrderStatus {
OrderStatus nextStatus() {
return switch (this) {
Pending() => Processing(),
Processing() => Shipped(),
Shipped() => Delivered(),
Delivered() => this, // 最終ステータス
};
}
}
void main() {
OrderStatus status = Pending();
print('現在のステータス: ${getStatusDescription(status)}'); // `switch`文の活用
status = status.nextStatus(); // クラスの拡張メソッドを活用
print('次のステータス: ${getStatusDescription(status)}');
}
状態ごとに固有の振る舞いを実装しつつ、網羅性チェックを効かせたいのであれば、このような表現方法も有効でしょうね。
ただその必要がないのであればenumで表現する方が簡便ですので、この使い所の意識が必要そうですね。
参考
Discussion