エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Enumとてもつらい、でも負けない

列挙型、JavaでいうならEnum型、使っていますか。使わないわけにいきませんよね。
でも、Enumを使っていたせいで辛い目にあったことありませんか。ないですか。それならきっともうすぐに辛い目にあうと思います。
Enumはすべてのプログラマに等しく辛みを与えてくれるからです。そんな辛みについて、ちょっと一緒に直視してみましょう。
エムスリーエンジニアリンググループ、Unit1(製薬企業向けプラットフォームチーム)三浦(@yuba@reax.work) [記事一覧 ]がお送りいたします、エムスリー Advent Calendar 2023の6日目です。

アプリケーションプログラミング上の辛み

リストの要素になにかしらの種別がある場合、その種別を表現する必要がどうしても出てきます。そうした場合にEnumの出番があります。
種別が2種類だけならisなんとか的なフラグを持つだけでも十分かもしれませんが、3種類以上ある場合や将来的に3種類以上になる可能性が見えるならばEnumを使うことになりますね。

今、「将来的に」と書きました。将来的に増えること、これがEnumの辛みの根源です。

例を書いてみましょう。本記事ではJavaで書いていきますがほとんどの言語について通じる内容になると思います。
今回書く例は「従業員」、その種別の表現です。

public enum EmployeeType {
    FULL_TIME,
    PART_TIME,
}

正社員、パートタイム社員の2種類の従業員を扱えるようになっています。
さてここに変更。会社の方針の変更により人材派遣を受けることになり派遣社員という種別もシステムで扱う必要が生まれました。

 public enum EmployeeType {
     FULL_TIME,
     PART_TIME,
+    DISPATCH,
 }

このときにどのような問題が起きるか。

1. 既存のif文が偶発的に意図しない方に倒れる

コードには、

if (employee.type == EmployeeType.FULL_TIME) {

と書いてある場合と

if (employee.type == EmployeeType.PART_TIME) {

と書いてある場合があります。前者の場合、修正なしだと派遣社員はelse側、パートタイムと同じ扱いになります。後者の場合は正社員扱いですね。
もちろん、これらのif文を書いた時点では派遣社員の存在を意識してはいませんから、新規追加した種別がどちら側に倒れるか意識してif文を書いていたわけがありません。すべてのif文が棚卸し対象であり、全分岐がどちらに倒れるべきであるかを検討する必要があります。

そして、大変な思いをしてそれをやったとしても本当に漏れなく対応できたかどうか、コードレビューで確認することが非常に難しいです*1
すべてのif文に「派遣社員はこういう理由でここでは正社員扱い」みたいにコメントを書きます? はい、大変ですし泥臭いですがそのくらいしか方法がありません。

2. switch文に至っては「どちらでもない」で処理不発に

switch文の場合は、

switch (employee.type) {
    case FULL_TIME:
        // 正社員の処理
        break;
    case PART_TIME:
        // パートタイムの処理
        break;
}

となっていますから、派遣社員の場合はどちらの処理も実行されません。二者択一で何かしらやっておかないといけなかった処理がどちらも実行されずデータの整合性が無残にも破綻といったことが起こります。

switch文の場合はdefaultを書いてランタイムにエラー検知することができるのですがね、ユニットテストもステージング環境QAもカバレッジ100%はあり得ない以上、本番でいつか火を噴く地雷であることは変わりません。

アプリケーションプログラミング上の対策

1. 分岐条件をEnumに持たせる

ifやswitchで「どの種別か」を判定させない、種別オブジェクトの方に「どの処理をすべきか」を持たせるという、OOPとしてはまっとうな考え方です。

public enum EmployeeType {
    FULL_TIME(/* useMonthlySalary =*/true),
    PART_TIME(/* useMonthlySalary =*/false),
    DISPATCH(/* useMonthlySalary =*/true),
    ;

    public final boolean useMonthlySalary;

    EmployeeType(boolean useMonthlySalary) {
        this.useMonthlySalary = useMonthlySalary;
    }
}

こんな風に給与計算のところでの分岐の判定結果をEnumに持たせてしまうと、

if (employee.type == EmployeeType.FULL_TIME || employee.type == EmployeeType.DISPATCH) {
    // 月給制の処理
} else {
    // 時給制の処理
}

ではなく

if (employee.type.useMonthlySalary) {
    // 月給制の処理
} else {
    // 時給制の処理
}

と書けるようになり、Enum要素追加時の要件棚卸しはEnumの方に集約されます。めでたしめでたし。

ただしこの方法、この説明だけなら一見美しいのですが実際の開発になると「ほんとにやるかあ⋯⋯?」ということになってきます。
なにしろ、判定箇所はどんどん増えていきます。Enumはあっという間に大量のフラグを装備した巨大なロジック塊となっていきます。コンストラクタが(true, true, false, 2, null, false, ...)のような地獄になっていきます。
上記サンプルでは少しでもわかりやすいようにとパラメータに/* useMonthlySalary =*/みたいなコメントを書いていますが、こういう努力をしてもフラグが増えてきたらまず扱いきれなくなってくることは必定、このEnumに持たせる手法だけだと限界がすぐなのは明らかでしょう。

2. switch文でなくswitch式を使う

switch式はJava 12から登場の比較的新しい構文なので、まだなじみのない方もいるかもしれませんね。一言で説明してしまうと、switch式とはswitch文をベースにしつついくつかの点で強化された複数分岐構文なのですが、中でもswitch文と違って網羅性チェックをしてくれるという大きな特徴があります。
すなわち、以下の式はコンパイルエラーになるのです。

var result = switch (employee.type) {
    case FULL_TIME -> /* 正社員の処理 */ ;
    case PART_TIME -> /* パートタイムの処理 */ ;
    // DISPATCHのケースがないのでコンパイルエラーになる: switch式がすべての可能な入力値をカバーしていません
}

既存のif文、switch文をすべてswitch式に書き換えれば、派遣社員対応の差分としても漏れがないことをレビュー確認可能になります。

実際にはこの2.と上記の1.の組み合わせで、つまりEnumにとって本質的である条件はEnumに持たせつつそれ以外の多くの判定はswitch式で書くという分業で漏れのないEnum要素追加を実現していくことになるでしょう。
Java以外の言語であっても、網羅性のある複数分岐構文を持つコンパイル言語ならまったく同じ考え方で静的に解決できます。

⋯⋯アプリケーション本体のプログラミングに限っては。

データ分析の辛み

ある程度以上のユーザーを獲得しているシステムならば、何らかの形で実績のデータ分析という活動が行われていることでしょう。
データ分析をするのは通常、システムアプリケーション本体ではなく、分析用DBに転送してデータマートやデータウェアハウスといった形でになりますね。リードレプリカに分析用SQLを投げるだけの形かもしれませんが、どちらにしろアプリ本体の外側で行種別判定のSQLが書かれていることになります。

CASE e.type WHEN 'FULL_TIME' THEN ... ELSE ... END

こんなSQLが気付かず放置されていれば、派遣社員はパートタイマー扱いとなりますね。逆パターンのCASE-WHEN式であれば正社員扱いになります。if文と同じ現象がここに。
もちろん、抽出条件の方にも同じことが起こります。

WHERE e.type = 'FULL_TIME'

こう書いた抽出式で派遣社員が含まれないのは意図通りなのか問題。

ここでSQLの辛みは、アプリケーションプログラミング本体とはまた違った味わいがあります。

  • アプリケーションプログラムほど強力な参照箇所検索は使えないことが多い。
    Enum定義を自分で持っているわけではないですからね。さらに、特に今回の例のような「type」という特異性の低いカラム名だと検索ノイズが多すぎて全文検索さえ役に立ちません。
  • 多くの場合、アプリケーション本体の開発エンジニアとデータ分析エンジニアは別チームである。
    その結果、Enumの要素を追加したという事実自体がデータ分析エンジニアに伝達し漏れてしまいがち。

前者は技術的、後者は人間系の問題ですが、これ結局は人間系の問題に行き着きます。
だって、前者の問題があるからデータエンジニアは聞いてさえいれば絶対に止めるのです。Enumの追加はちょっと待ってくれと。
使用箇所を棚卸しして対策入れるまでに急いでも○週間かかるから、それまで本体改修のリリースは待ってくれと。
ええ、時間はかかりますとも、それこそ前者の理由で。かつ、データ連携先がチーム外や社外にも及んでいれば気が遠くなります。見込みを○週間と即答することさえ難しいですね。

データ分析のことまで考慮した対策

1. データ分析エンジニアとのコミュニケーションを密にする

当たり前すぎてつまらない話ですが、避けられない話です。どんな方法ができるかご考慮ください。定期ミーティングを持つ、兼任メンバーを置く、設計レビューのチェック項目にデータチーム承認というのも設けておくなどです。
ちなみに私のチームでは全部やっていますし、なんでしたら私がその兼任メンバーです。

2. Enum要素は増やさない

ちょっと待ってそれはどうなんだ、Enum要素を増やさないなんてできるのか、実際いま派遣社員を追加しないといけないではないか。

これはこう考えます。
設計的には、「派遣社員は正社員の一種の新しい内部分類である」などとまず位置づけるのです。
これは設計の中だけで完結する納得ではなく、利用者サイドとも共有しておくべき認識ですね。
そして、データ定義としてはtype == FULL_TIMEなる従業員をさらに細かく分類する種別として

public enum FullTimeEmployeeType {
    REGULAR,
    DISPATCH,
}

を作り、そういうフィールドを従業員クラスに新設するのです。DB的には従業員テーブルにカラムを追加と。

派遣社員は「正社員の一種である」以上、アプリロジックでも分析ロジックでも既存のコードの分岐は基本的に間違いではありません。
その上で、派遣社員は正社員は処理ロジック違えないといけないところあるんだっけと考えてその違うところだけを修正対象に挙げていけばよいとなります。

もっとも、この手法も問題点が多いことは一目見てよくわかると思います。
1カラムで十分な種別わけのためにカラム数が増えていくのはもちろん設計として美しくないわけですが、デメリットは単に美感だけの問題ではありません。コードやスキーマの学習コストが上がるわけですから、人の育てにくさというジャブが開発にじわじわ打ち込まれます。
switch式によって考慮漏れを確実に捕まえる作用も期待できなくなります。要素が増えないんですから。

ここから先は決まった答えはなく、議論しながらチームとして納得できる解決方法を毎回導き出していくものになってきます。
データエンジニアチームがEnum追加を許容できる場合もあるでしょう。
それが無理で「内部分類」カラムを増やすしかなくなったときも、アプリ上では単一の分類軸に見えるようなメソッドを設けることもできるかもしれません(これは私はやったことありませんが。やったことある方いらしたら、この方法で新たな辛みが生まれないかなどの知見をいただけたらうれしいです)。

public IntegratedEmployeeType getIntegratedType() {
    return switch (this.type) {
        case FULL_TIME -> switch (this.fullTimeEmployeeType) {
            case REGULAR -> IntegratedEmployeeType.REGULAR;
            case DISPATCH -> IntegratedEmployeeType.DISPATCH;
        };
        case PART_TIME -> IntegratedEmployeeType.PART_TIME;
    };
}

public void setIntegratedType(IntegratedEmployeeType type) {
    switch (type) {
        case IntegratedEmployeeType.REGULAR -> {
            this.type = FULL_TIME;
            this.fullTimeEmployeeType = REGULAR;
        }
        case IntegratedEmployeeType.DISPATCH -> {
            this.type = FULL_TIME;
            this.fullTimeEmployeeType = DISPATCH;
        }
        case IntegratedEmployeeType.PART_TIME -> {
            this.type = PART_TIME;
            this.fullTimeEmployeeType = null;
        }
    };
}

まとめ

結局最後は議論と納得だという話に落ち着いてしまいましたが、設計はいつもそうです。
ひとりが導き出した最適なる設計——そんなものはありもしないのですが——よりは、チームで問題点を共通認識として持ち、各案のメリットデメリットを共有し、最終的に何を捨てて何を取ったかをみんなが知っている、そうやって作った設計からの方がより良いコードが生まれるのはごく自然なことです。

We are hiring

アプリ開発エンジニアがデータエンジニアの方も向いている、そういう開発環境はエムスリーの自慢ですが、最初からそうであったわけではありません。いくつもの苦い経験を積みながら反省の末に作り上げてきたものです。

アプリエンジニアの顔が見える(リモートですが!)データ分析のお仕事にご興味のあるデータエンジニア、機械学習エンジニアの皆様、そういう開発環境にちょっとでもご興味ありましたらこちらのページからどうぞ。応募を前提にしないカジュアル面談もやっています。

jobs.m3.com

*1:コードレビューで確認困難というのは、改修している本人にとっても漏れなく作業するのが難しいということでもあります