📑

型を使ったプログラミングをしよう

2024/10/22に公開

何かソフトウェアを書く際に、特定のデータを型で表現するようなプログラミングをよく行っています。
このような手法はクラスの使い方を覚えた時点で理解できる内容なのですが、意外と説明されることがない気がします。

我々はプログラム上でデータを扱う際にはなんらかのデータ型を利用しています。
整数を扱いたければint型、文字を扱いたければString型といったプログラミング言語であらかじめ用意されているプリミティブ(基本的)な型を使います。もっと発展的な型だと、日付を扱うDateTime型であったり、色を扱うColor型などがライブラリとして公開されていたりして、それらを使うことでプログラムで様々な表現をすることが可能になります。

しかし世の中には用意されてないようなデータ型も存在します。例えば、日本の電話番号のみを表現するような型はおそらくないでしょう。
そのような場合には、自分で型を定義する必要があります。このような型のことを、int型などのプリミティブな型と対比してドメインプリミティブな型と呼んでいます。

自分の作りたいソフトウェア専用のドメインプリミティブな型を定義して利用することが、この記事のタイトルである、「型を使ったプログラミング」ということになります。

ドメインプリミティブな型の利点

データにドメインプリミティブな型がついていると、安心して利用できるというのが大きな利点です。

電話番号を扱うアプリケーションの場合、電話番号の型をString型として表現する方法とアプリケーション専用のPhoneNumber型を定義して利用する方法があります。

前者だと電話番号が文字列であるため、それがどのようなデータなのか読み取ることは困難です。
もしかしたら、電話番号として有効でないような形式のものが含まれている可能性もあります。

後者では自分のアプリケーションで電話番号として扱いたいものを、PhoneNumber型として定義します。電話番号として有効でないようなデータでは、PhoneNumber型を作れないようにすることで、不正な値が入り込むのを防ぐことができます。

適切にドメインプリミティブな型が作成されていれば、その型を使う人はint型やDateTime型と同じような安心感でプログラミングを行うことが可能になります。

ドメインプリミティブな型を定義する

データ型を具体的に定義する方法はクラスを使うことです。
クラスを使うことで、プリミティブなint, String型やDateTime型に似た使い心地の型を定義することが可能です。

今回の例では電話番号を表現するPhoneNumber型を定義してみます。
言語はDartを使いますが、他のプログミング言語でも同様に定義することができるはずです。

型をクラスを使って定義する

まずはクラスを定義します。名前はPhoneNumberであり、インスタンス変数としてvalueを持ちます。
このvalueには、電話番号の値を持たせます。

class PhoneNumber {
  String value;

  PhoneNumber(this.value);
}

この時点ではvalue変数にはなんの制限もないため、携帯電話でない文字列を使ってPhoneNumber型を作成することができてしまいます。

そこでコンストラクタでPhoneNumberが初期化される際に、valueのチェックを行います。
このチェックを行うことで、PhoneNumber型が必ず特定の文字列となるように制約をかけます。

電話番号の仕様についてはこのサイトを参考にしました。

/// 電話番号は10 or 11桁の数字
class PhoneNumber {
  String value;

  PhoneNumber(this.value) {
    if (!isValidLength(value)) {
      throw ArgumentError('電話番号は10 or 11桁の数字です。 ($value)');
    }
    if (!isValidNumber(value)) {
      throw ArgumentError('電話番号は数字のみです。 ($value)');
    }
  }

  /// lengthのチェック
  static bool isValidLength(String value) {
    return value.length == 10 || value.length == 11;
  }

  /// 数字のみかのチェック
  static bool isValidNumber(String value) {
    final regex = RegExp(r'^\d+$');
    return regex.hasMatch(value);
  }
}

void main() {
  // このように初期化する
  final number = PhoneNumber('08012345678');
}

バリデーションの項目はstaticメソッドとして定義を行い、仕様に合致しないような場合はエラーを起こすようにします。これにより、PhoneNumber型に不正な値が入るのを防ぐことができます。

このバリデーションをどこまでやるかは、ドメインに依存します。
例えば、日本の電話番号は国内の場合のプレフィックスとして0が付きますが、そこまでのチェックは不要かもしれません。

もしかしたら、あるソフトウェアでは携帯電話番号のみを扱いたく、090/080といった特定の数字から始まるものしか受け付けない可能性もあります。

ドメインプリミティブな値はあくまで自分のソフトウェアのための型なので、PhoneNumber型はあらゆる電話番号を表現する訳ではありません。
”自分のソフトウェアでの”というコンテキストでのPhoneNumber型なのです。

イミュータブルにする

プリミティブ型である、int型やString型はイミュータブルな値です。
つまり、int型やString型は一度作成されたらその値は変更されず、新しい値を作成する場合は、元のオブジェクトを変更するのではなく、新しいオブジェクトが作られます。

ドメインプリミティブな型もプリミティブ型にならって、イミュータブルにするべきです。

ミュータブルの場合に関数などでデータを勝手に書き換えられてしまい、その影響が呼び出しもとに及ぶ可能性があります。

void func(PhoneNumber number) {
  // numberを勝手に書き換える
  number.set("080xxxxxxxx");
}

final number = PhoneNumber("0800000000");
func(number);

print(number); // "080xxxxxxxx"になってしまう

またset〇〇のように値を書き換えるメソッドを用意してしまうと、せっかくコンストラクタで作成したデータの制約を破壊してしまう可能性もあります。

イミュータブルな型の定義の仕方は、インスタンス変数を変更不可能にすることです。
Dart言語であれば、インスタンス変数をfinalとして定義することで、コンストラクタで初期化されたのちに変更することが不可能になります。

class PhoneNumber {
  final String value;

  ...
}

注意することは、Listなどのミュータブルな値を持つクラスの場合、finalをつけても値を変更することができるようになるため、厳密にイミュータブルではなくなってしまいます。変更不可能なListを利用するか、注意して扱う必要があるでしょう。

またドメインプリミティブな型で、値を変更するようなメソッドが欲しい場合は自身の新しい値を作り直して返してあげるようにするといいでしょう。

class PhoneNumber {
  ...

  PhoneNumber update(String value) {
     return PhoneNumber(value);
  }
}

PhoneNumber型ではあまり値を更新したいような要件はなさそうですが、ドメインプリミティブな型によっては、値を変更したいみたいなパターンは割とよくあります。

等価性を判定できるようにする

int型やString型では値が同じであれば==で比較を行った場合、当然trueとなります。
ドメインプリミティブであるPhoneNumber型でも同様に、等価性を判断できる方が扱いやすいです。

Dart言語ではoperatorをオーバーライドする機能があります。
それにより、PhoneNumberの電話番号が同じだったら、オブジェクトが違っていても==trueを返すようにすることができます。

class PhoneNumber {
  ...

  
  bool operator ==(Object other) {
    return other is PhoneNumber && other.value == value;
  }

  
  int get hashCode => value.hashCode;
}

上記の実装で以下のような結果が得られるようになる。

void main() {
  final number1 = PhoneNumber('08000000000');
  final number2 =  PhoneNumber('08000000000');
  
  print(number1 == number2); // true
}

PhoneNumber型では必要ありませんが、==以外にも+>といったオペレーターをオーバーライドすることができます。
それらを利用して、プリミティブ型のように、ドメインプリミティブな型を操作することができるようにになります。

ドメインロジックを持たせる

プリミティブ型には便利なメソッドが用意されてます。
例えばDartのString型では、padLeftというメソッドがあります。42という数字を00042のように特定の文字数にするためのメソッドです。

String number = "42";

// 長さを5文字にして左側をゼロで埋める
String paddedLeft = number.padLeft(5, '0');
print(paddedLeft); // "00042"

このような型特有の便利なメソッドを持たせることで、データ型は使いやすくなります。
ドメインプリミティブな型でも同様に、ドメイン特有の便利なメソッドを持たせることで、型の表現力を向上させます。

電話番号の例であれば、例えばSMSを送信できるかどうかを判定するメソッドを持たせてみるのはどうでしょうか。以下のように実装してみます。

class PhoneNumber {
  ...
  
  bool canSendSms() {
    return value.length == 11 && !value.startsWith('050');
  }
}

携帯電話番号である、11桁の番号であり、IP電話である050以外であれば、SMSが送信可能であると判断するようにしてみました。

この実装により、次のようにSMSが送信可能かを判定することができるようになりました。

final number = PhoneNumber('08000000000');
number.canSendSms()

他にも便利なメソッドとして、080-1234-5678みたいなフォーマットの電話番号をパースして、PhoneNumber型を作成するファクトリー的なstaticメソッド、電話番号の一部を隠した文字列を返す機能など。

これらは自分のソフトウェアに必要な機能を追加していけば、より使いやすいドメインプリミティブな型へと発展していくはずです。

どんな時にドメインプリミティブな型を作るべきか

ドメインプリミティブな型を書くことは、コストがかかることも事実です。
値によってはドメインロジックが少なく、String型で済むような場合も多くあります。

電話番号の例では、電話番号から固定電話、携帯電話などの識別を行うロジックが必要がない場合、ロジックが存在していても認証などの特殊な場合のみで再利用が不要な場合はString型で済ました方が、トータルとしてコストがかからないかもしれません。

逆に電話番号に関するソフトウェアであれば、PhoneNumber型がソフトウェアの主要なデータ型となり、たくさんのロジックが実装される可能性もあります。

今回は説明のために、PhoneNumber型を例に挙げましたが、必ず電話番号はPhoneNumber型を作ろうという主張ではないです。

大切なのは、データの制約を守る価値がどれだけ高いか、ドメインロジックがどれだけ実装されるかを予想して、自分のソフトウェアにとって価値の高い型を作ることです。

まとめ

ドメインプリミティブな型を使ったプログラミングは、ソフトウェアの安全性と表現力を高める強力な手法です。データ型にドメイン特有のバリデーションやロジックを持たせることで、不正なデータがプログラム内に入り込むリスクを抑え、意図した使い方を強制できます。

また、イミュータブルな型設計にすることで、予期せぬ変更によるバグを防ぎ、より安定したコードを書くことが可能です。等価性のオーバーライドやドメインに特化したメソッドを追加することで、使いやすく強力な型を実現できます。

ただし、どのデータに対してドメインプリミティブな型を導入するかは、ソフトウェアのニーズや開発コストを見極めて判断する必要があります。ドメインロジックが多く含まれる重要なデータに対しては、この手法が非常に有効です。

最終的には、適切な型の設計がコードの可読性、保守性、信頼性を向上させることに繋がります。

Discussion