3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CBcloudAdvent Calendar 2024

Day 23

Dart 数値に単位をつけたいよ

Last updated at Posted at 2024-12-25

単位、気にしてますか?

大学生の頃には気にしたでしょうが、社会人となった今ではあまり気にしていない人も多いのはないでしょうか。

弊社CBcloudが開発している配送ドライバー向けアプリ「ピックゴーパートナー」では、「円(¥)とポイント」という2つの(広義の)通貨単位を扱っています。

そうすると困ったことになるのが単位です。

異なる単位の数値は足したり引いたりできない、というのは小学校の頃に習うものの、それを防ぐためのよくある対応は、変数名の接頭or接尾辞を工夫して人間の注意力を信じるという信じられないやり方です。

  int usersPoint = 1000;
  int bonusPoint = 200;
  
  //ここがレビューで見つかると、運の良いことに神経を疑われます
  final totalYen = usersPoint.toYen() + bonusPoint;
  
  //正しくは
  //final totalYen = (usersPoint + bonusPoint).toYen();

そうです。信じられても困惑するだけなので、「コンパイルエラーとなるように単位をつけて四則演算を制御したい」というのが本文の趣旨です。

前提として、複雑かつ間違いの許されない類の計算はバックエンドの領分だと思います。ただ、フロントエンドでも参考値の表示などママあるケースのためのモノとして考えます

Unit Of Measure があればよかったのに

F#には標準搭載されているUnit Of Measureなる仕様はDartに存在しないので、地道にクラスを作っていきます。

(F#ってなに……?)

発想としては、オリジナルの数値型(っぽいもの)を作りたいのでDart本体のnum.dart(=int型やdouble型の基底クラス)を見にいくのが早いです。

  • オブジェクトの等価比較
  • 演算子(+, -, *,...など)
  • floorなど変換のためのメソッド

が最低限あればよさそうです。
そこで以下のように通貨の基底クラスを作成します。

sealed class CurrencyAmount<T extends CurrencyAmount<T>> {
  final num _value;
  const CurrencyAmount._(this._value);

  T operator +(T other);
  T operator -(T other);
  T operator *(num factor);

  //割り算は2種類用意する
  num divBySameType(T other);
  T divByNum(num factor);

  @override
  int get hashCode => _value.hashCode;

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other.runtimeType == runtimeType &&
        other is CurrencyAmount<T> &&
        other._value == _value;
  }

  //例としてfloorのみ実装
  T floor();
}

ポイントとしてはジェネリクス<T>を使用して、四則演算の相手(=引数)と結果(=戻り値)の型をどちらもサブクラス単位で同じ型に指定できるようにします。

CurrencyAmount<T extends CurrencyAmount<T>>は、単にCurrencyAmount<T>とすることもできますが、より型安全にするため、T型自体に縛りをつけたいので上記のようにします

※これはCRTPという考え方らしいです

・外部からbunusPoint.valueのような形で直接値を取得されると、せっかく制限を加えた意味がなくなるので、_valueprivateにします

・DartにはJavaのような「メソッドの多重定義(オーバーロード)」の仕組みがないので、割り算は、割る数と割られる数が同じ型かどうかで2種類用意します

Sealedを使っているのは、公式Docにもある通り、「クラスが独自のライブラリの外部で拡張または実装されることを防ぐ」ためです。試しに違うファイルで継承しようとするとエラーになります。これはまさにsealed本来の意味(=封をされた)です

あとは同じファイル内にYenとPointのクラスを実装していくだけです。

final class Yen extends CurrencyAmount<Yen> {
  factory Yen(num value) {
    return Yen._(value);
  }

  const Yen._(super.value) : super._();

  @override
  Yen operator +(Yen other) => Yen._(_value + other._value);

  @override
  Yen operator -(Yen other) => Yen._(_value - other._value);

  @override
  Yen operator *(num factor) => Yen._(_value * factor);

  @override
  num divBySameType(Yen other) => _value / other._value;

  @override
  Yen divByNum(num factor) => Yen._(_value / factor);

  @override
  String toString() => $_value';

  @override
  Yen floor() => Yen._(_value.floor());
}

Yenは上記のように実装します。Pointも同様ですがYenへの変換ロジックを拡張で追加します。別にPointクラス内にもtoYenメソッドは書けるのですが、別で書いたほうがわかりやすいかな、というだけです。

final class Point extends CurrencyAmount<Point> {
  //Yenクラスと同様に実装する
  //・・・
  //省略
  @override
  Point floor() => Point._(_value.floor());
}

// 基底クラスにないメソッドは拡張で書いたほうがわかりやすい気がする
extension PointExtension on Point {
  Yen toYen(double rate) => Yen._((_value * rate));
}

たしかめる

void main() {
///加算・減算
  final usersPoint = Point(500);
  final bonusPoint = Point(100);
  //念の為の注:rateは適当な値です
  final totalYen = (usersPoint + bonusPoint).toYen(0.1);
  // 下はコンパイルエラー
  // final totalYen = usersPoint.toYen() + bonusPoint;
  print(totalYen); // ¥60

///掛け算・割り算
  final weeklyPay = Yen(1000);
  final monthlyPay = weeklyPay * 4;
  print(monthlyPay); // ¥4000

  final weekCount = monthlyPay.divBySameType(weeklyPay);
  // 下はコンパイルエラー
  //final weekCount = monthlyPay / weeklyPay; 
  print(weekCount); // 4.0

  final daylyPay = monthlyPay.divByNum(30);
  print(daylyPay.floor());// ¥133



/// 等価判定
  print(Yen(1000) == weeklyPay); // true
  //型が異なるワーニングが出る
  print(Yen(1000) == Point(1000)); // false
}

これでもうポイントと円の計算に神経症的になる必要はありません:tada:

最後にお決まりの……

CBcloudではモバイルエンジニアを募集しています!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?