単位、気にしてますか?
大学生の頃には気にしたでしょうが、社会人となった今ではあまり気にしていない人も多いのはないでしょうか。
弊社CBcloudが開発している配送ドライバー向けアプリ「ピックゴーパートナー」では、「円(¥)とポイント」という2つの(広義の)通貨単位を扱っています。
そうすると困ったことになるのが単位です。
異なる単位の数値は足したり引いたりできないというのは小学校の頃に習うものの、それを防ぐためのよくある対応は、変数名の接頭or接尾辞を工夫して人間の注意力を信じる、という方法がスタンダードなものだと思います。
int usersYen = 1000;
int bonusPoint = 200;
//ここがレビューで見つかると運がいいですし、神経を疑われます
final totalYen = usersYen + bonusPoint;
そこで「コンパイルエラーとなるように単位をつけて四則演算を制御したい」というのが本文の趣旨です。
複雑かつ間違いの許されない計算はバックエンドが担当すべきですが、フロントエンドでも参考値の表示やローカル検算などで単位を厳密に扱いたいケースがあるため、本手法を提案します。
Unit Of Measure
この記事のきっかけになったのは、F#には標準搭載されているUnit Of Measureなる仕様を知ったことですが、Dartに存在しないので、地道にクラスを作っていきます。
(F#ってなに……?)
オリジナルの数値型を作りたいのでDart本体の num.dart(=int型やdouble型の基底クラス)を真似します。
実装を見てみると、
- オブジェクトの等価比較
- 演算子(+, -, *,...など)
- floorなど変換のためのメソッド
が最低限あればよさそうです。
そこで以下のように同種単位の四則演算・比較を定義する抽象基底クラスを作成します。
import 'package:meta/meta.dart';
/// 同種単位の四則演算・比較を定義する抽象基底クラス
@immutable
abstract class CurrencyAmount<T extends CurrencyAmount<T>>
implements Comparable<CurrencyAmount<T>> {
final num _value;
const CurrencyAmount._(this._value);
/// 同種同士の加算
T operator +(T other);
/// 同種同士の減算
T operator -(T other);
/// 数値乗算
T operator *(num factor);
/// 同種同士で割った結果を数値で取得
num divBySame(T other);
/// 数値で割った結果を同種型で取得
T scaleBy(num factor);
/// 切り捨てなどの変換をサブクラスで実装
T floor();
@override
int compareTo(CurrencyAmount<T> other) => _value.compareTo(other._value);
@override
bool operator ==(Object o) =>
o is CurrencyAmount<T> &&
runtimeType == o.runtimeType &&
_value == o._value;
@override
int get hashCode => _value.hashCode;
} hashCode => _value.hashCode;
}
ポイントとしては<T>を使用して、四則演算の相手(=引数)と結果(=戻り値)の型をどちらもサブクラス単位で同じ型に指定します。
・CurrencyAmount<T extends CurrencyAmount<T>>は、単にCurrencyAmount<T>とすることもできますが、より型安全にするため、T型自体に縛りをつけたいので上記のようにします
※これはCRTPという考え方らしいです
・外部からbunusPoint.valueのような形で直接値を取得されると、せっかく制限を加えた意味がなくなるので、_valueとprivateにします
・DartにはJavaのような「メソッドの多重定義(オーバーロード)」の仕組みがないので、割り算は、割る数と割られる数が同じ型かどうかで2種類用意します
あとは同じファイル内にYenとPointのクラスを実装していくだけです。
import 'currency_amount.dart';
class Yen extends CurrencyAmount<Yen> {
const Yen(num v) : super._(v);
@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 divBySame(Yen other) => _value / other._value;
@override
Yen scaleBy(num factor) => Yen(_value * factor);
@override
Yen floor() => Yen(_value.floor());
@override
String toString() =>
NumberFormat.currency(symbol: '¥', decimalDigits: 0)
.format(_value);
/// 円をポイントに変換
Point toPoint(num rate) => Point(_value / rate);
}
Yenは上記のように実装します。Pointも同様ですがYenへの変換ロジックを拡張で追加します。
また以下のように、書きやすさのためにドットで呼べるExtensionも追加で用意します。
import 'yen.dart';
import 'point.dart';
extension CurrencyExtension on num {
/// 例: 100.yen → Yen(100)
Yen get yen => Yen(this);
/// 例: 50.point → Point(50)
Point get point => Point(this);
}
使ってみる
void main() {
// 加算・変換
final usersPoint = 500.point;
final bonusPoint = 100.point;
// レートは例: 1ポイント = 0.1円
final totalYen = (usersPoint + bonusPoint).toYen(0.1);
// final wrong = usersPoint.toYen(0.1) + bonusPoint; // コンパイルエラー
print(totalYen); // ¥60
// 掛け算・割り算
final weeklyPay = 1000.yen;
final monthlyPay = weeklyPay * 4;
print(monthlyPay); // ¥4000
final weekCount = monthlyPay.divBySame(weeklyPay);
// final wrong2 = monthlyPay / weeklyPay; // コンパイルエラー
print(weekCount); // 4.0
final dailyPay = monthlyPay.scaleBy(1 / 30);
print(dailyPay.floor()); // ¥133
// 等価判定
print(Yen(1000) == weeklyPay); // true
// print(Yen(1000) == Point(1000)); // コンパイルエラー or false
}
これでもうポイントと円の計算に神経症的になる必要はありません![]()
お決まりの
CBcloudではモバイルエンジニアを募集しています!