見出し画像

Goにおける汎用型から独自定義型への移行

目次


概要

本記事は Go 言語 Advent Calendar 2023 の13日目の記事です。

背景

私はブルーモ証券株式会社というスタートアップ企業で証券アプリ Bloomo の開発に従事しています。Bloomo は米国株とETFを簡単に運用できるアプリです。 (bloomo.co.jp)

有価証券を扱うアプリのため、数値計算を正確に行う必要があります。Bloomo は銘柄を0.0001株から買えるようになっており、米国株を取り扱うので当然為替も考慮しなければなりません。そのため、扱うデータ型は整数型ではなく実数型となります。

Go のプリミティブな実数型である float32, float64 は浮動小数点型であり、丸め誤差を含んでしまいます。小数点以下の計算を厳密に行うためには decimal 型を用いる必要があります。Go は標準ライブラリとして decimal 型を提供しておらず、自作するかサードパーティライブラリを使用するかが選択肢ですが、今回は有名な github.com/shopspring/decimal を採用した前提でお話を進めます。

ようやく本記事で話したい内容についての説明に移ります。Bloomo では日本円と米ドルの少なくとも二つの貨幣に対して decimal 型で表現する必要がありました。元々は decimal がどちらの通貨を表しているのかを ハンガリアン記法 で表現していましたが、変数名を完全に管理することはコードレビューを実施していても難しく、 完全に信頼できる情報とはなりませんでした。そこで、日本円と米ドルそれぞれに専用の型を与えようと考えました。日本円と米ドルをいわば decimal のラッパーのような形で型で表現する上で検討した方針がいくつかあり、トレードオフを含めてそれを記録しようというのが趣旨です。

本記事では最終的なブルーモでの決定とその影響についても紹介したいと思います。

この記事で書かないこと

  • decimal 型のライブラリ選定方法

  • 丸め誤差の詳細やデータ保持方法の詳細

  • 正確なシステムの仕様 (本記事に記載される処理はシステムのものとは異なるのでご注意ください)

方針検討

前提

方針を選ぶ上でのブルーモにおける開発状況を踏まえた前提を記載します。

  • github.com/shopspring/decimal を使用します

  • JPY, USD は decimal 型による四則演算を行います

  • 品質はもちろん落とさない前提で速度も重視しています

  • 既に複雑な処理内で decimal 型のまま扱っており移行コストもかかります

  • 開発メンバーは Go の達人ではなく Go も書けるけど他の言語の方が得意なメンバーで構成されています

  • 私は Bloomo の開発に従事し始めたところでドメイン知識が不足していました

素案

いくつかの方針を紹介します。それぞれの名前はなるべく使われていそうな表現にしたものの、あくまで私が名付けたものであり、一般的な用語ではない点にご注意ください。

Struct with a decimal field

type JPY struct {
	d decimal.Decimal
}

フィールドととして decimal 型を保持し、四則演算の処理を明示的に委譲していきます。

面倒なポイントとしては、全ての四則演算のレシーバを書き直す必要があることと、四則演算のシグネチャや命名の難しさが挙げられます。四則演算を行うと言っても、たとえば日本円を左項としたときに、右項になるのは為替レートかもしれませんし、按分計算のための単位を持たない係数かもしれません。ドメイン駆動設計の文脈ではむしろしっかりと考えて意思決定をしていかなければいけない問題ではあるものの、今回のようにドメイン知識が不足している状況かつ短期的なスピード感も求められている状況ではやはりコストは高くつきます。

また、クライアントはそれぞれのレシーバメソッドへ移行が迫られるため、クライアント側の改修コストも大きくなります。

以降述べる方法の中でこの方法が最もカプセル化ができており、移行を完了した後の複雑性は凝集されていると考えています。

Struct Embedding

type JPY struct {
	decimal.Decimal
}

Go の Embedding の機能を用いて、 decimal 型のすべてのレシーバをそのまま委譲します。 (参考 Effective Go#embedding)

上述のフィールドとして保持する方法を異なり、レシーバを書く手間は大幅に削減され、クライアントの書き換え量もかなり減少します。一方で、オブジェクトとして不要な振る舞いも多数追加されてしまい、クライアント側に一定の複雑性を許容してもらう必要があります。また、 overwride のようなことはできないため、それぞれのメソッドの返り値は decimal 型になってしまいます。たとえば日本円と日本円の加算の結果は JPY ではなく decimal 型で返却されるので、クライアント側に再度 JPY へ変換してもらわなければなりません。

Embedding された構造体は、上記の例だと JPY.Decimal という形でアクセスできてしまうため、 Struct with a decimal field と比べてカプセル化は弱くなってしまいます。

よって、上述の Struct with a decimal field と比べて移行コストを抑える代わりにドメインオブジェクトとしての役割は薄れると言ったトレードオフがあります。最低限今回達成したい型づけのメリットは享受できることも付け加えておきます。

Struct Definition and Embedding

type Currency struct {
	d decimal.Decimal
}

type JPY struct {
	Currency
}

上述のフィールドへの委譲とEmbeddingによる委譲を合わせたものです。

四則演算の計算は日本円と米ドルで共通化することができますが、クライアント側で再変換が必要になる点は解消できていません。

Defined type

type JPY decimal.Decimal

ぐっと記述をシンプルにして、 decimal 型を基底とした新しい型を定義する方法です。

フィールドに decimal 型を保持した構造体定義の方法と似たデメリットを持っており、すべてのレシーバを書き直す必要があります。

大きく異なる点は decimal 型との変換部分です。キャストによる割と自由な相互変換が可能になるためクライアント側の移行コストが減る部分もあるかもと考えていました。裏を返すと、気軽に型の変換ができてしまうので間違ったことをさせないという設計にはなっていないと感じています。

Type alias

type JPY = decimal.Decimal

Type alias も一応実装方針として紹介しておきます。

今回のケースにおいては Defined type の下位互換の立ち位置になると思うので以降言及しません。

ブルーモでの結論

ブルーモでは Struct Embedding を採用しました。

理由

移行コストと取り回しのしやすさのバランスを考えた結果です。

まず Struct with a decimal field は移行コストが大きすぎると判断しました。全てのレシーバの書き直しは筋力が必要な作業です。筋力による解決はとても Go らしい開発だと思っていますが、ちょうど開発メンバーから Go の記述量の多さについて不満が出ていたタイミングであったため、ここで筋力を酷使するのはチームマネジメントとして避けたい状況でした。

命名やシグネチャの設計についても、当時のドメイン知識の不足からコストが大きなものになると考えました。まずは適切な設計ができるところまでドメイン知識を会得する必要があり、そのためにはコードを読んだり書いたりすることが必要です。コードの読み書きをする上で、日本円と米ドルの型がないことによるストレスは大きく、型づけの設計のためにコードの読み書きを進めて習熟度をあげたいが、コードの読み書きの速度向上のために型づけをしたいというジレンマが生まれてしまいました。最初に型づけを簡単な手法で行って開発速度を向上させた後に、ドメインオブジェクトの振る舞いを正していく方針が全体を通して速いと判断して、より移行コストが小さい手法を採用することにしました。

次に Defined type ですが、これもレシーバの書き直しや設計が発生する点で Struct with a decimal field と同様の却下理由が生まれます。加えて、 Defined type は decimal 型との相互変換が容易にできてしまうため、制限を加えて安全なコードを書いていきたいという今回の意図を満たせない部分があると判断しました。

Struct Definition and Embedding を採用するならメリットが同等で移行コストの小さい Struct Embedding を採用しようと結論づけました。

良かった点

狙い通り、型を定義することで曖昧さが除去され、認知的負荷の減少に伴い開発速度が上がったと感じています。今回のように型を定義して値オブジェクトを表現するのか、プリミティブ型のまま扱うのかの線引きはしばしば議論されており難しい問題ですが、今回の事例では確実に型を定義することでより健全なコードになりました。

移行コストを抑えたことで、ある程度スムーズに移行が完了したことも良かった点の一つです。

悪かった点

当初の目標である型づけによる開発速度の向上は達成しており、移行コスト分の価値は十分にあったと思うので、絶対的に悪かった点というものは存在していないと感じています。そのため仮に他の選択肢、特に Struct with a decimal field を採用していれば、この点はもっと改善できたのにとったような、あくまで相対的な話をします。

  • 毎回 decimal 型への変換と decimal 型からの変換が必要

Struct Embedding を用いると、 Embedding された構造体のメソッドを直接呼び出すことができますが、先述の通りシグネチャが変わるわけではないので、 JPY + JPY がしたい時も、

jpy1 := NewJPY(100)
jpy2 := NewJPY(200)

// 引数には JPY のまま渡せないので、 decimal 型を指定する
decimalJPY := jpy1.Add(jpy2.Decimal)

// 返り値は decimal 型なので、 JPY に変換する
jpy3 := NewJPY(decimalJPY)

というように、毎回変換が必要になってしまいます。定型文ではあるものの、これをクライアント側で実施しなければならないので、実装時に面倒ですし、コードリーディング時のノイズになってしまいます。

これを解決するためには、

func (jpy JPY) Add(other JPY) JPY {
    return NewJPY(jpy.Decimal.Add(other.Decimal))
}

のようなことをする必要があると考えているのですが、これは Struct with a decimal field と同じ実装コストが必要なのに対して、カプセル化が不十分であることや不要なメソッドがあるというデメリットがより大きい手法になってしまいます。

  • 引数を強制できない

上の点とかなり近いですが、引数が decimal 型のままであることで、クライアント側に無限の選択肢を与えてしまっており、型を定義した旨みを最大限に活かせていないと感じています。

decimal 型に定義されているメソッドはそのまま全て使えてしまうので、たとえば JPY.Add(USD.Decimal) というようなコードを抑制する方法がありません。

シグネチャの観点のみではなく、意味的な観点でも事前条件の矯正などが難しいという課題があります。decimal 型は基礎的なライブラリのため、数値計算を行うドメインの振る舞いが定義されていますが、日本円の合算や按分において数値計算で用いる全ての振る舞いまでは必要ありません。不要なメソッドが多い意外にも、引数でたとえば負の値は絶対に受け付けないような振る舞いを日本円がする場合もそれを抑制する手立てはクライアント側に委ねられており、カプセル化がうまくできていない状況です。

逆に、引数が decimal 型であることによって、移行コストが抑えられた面が大きいので一長一短ではあります。

  • 結局コードだけ見せられた時に型が信用できない

一点目の毎回 decimal 型へ変換する制約からわかるように、クライアント側では decimal 型を一時的に直接触る機会が生じてしまいます。そのため、軽微なコードレビューなどで IDE は開かずにブラウザ上でレビューした場合などに、これは JPY なのか Decimal なのかを判断するのには結局負荷がかかる状態になってしまいました。

IDE を開けば解決する問題ではあるので、課題感は以前と比べてかなり小さくはなっていることは書き加えておきます。

今後の展望

型がある状況と型がない状況を比べると、圧倒的に型がある状況の方が開発体験が向上したので、現実的に可能そうな選択肢を持って移行が達成できたことは良かったです。

一方で、様々な課題感を挙げたようにドメインの基礎となるような型について、別ドメインのライブラリに振る舞いを委ねている状況は健全ではないと感じています。

今後の選択肢としては

  • Struct with a decimal field に移行する

  • Struct Embedding のまま開発を続ける

の二つで考えています。

移行を行う場合、開発コストは無視できません。現状の課題を考えると、移行コストを払って安全に開発をできる環境を整えることは価値があることだと思っていますが、それは現在開発中のコードベースが移行した後もそのまま使い続けられる前提での話です。当然、エンジニアとして5年10年とメンテナンス可能なプロダクトコードを書いていく所存ではあるので、技術的負債が貯まったからフルスクラッチでコードを書き直すみたいな判断をするつもりはありませんが、ビジネスの状況によっては同等のレベルでの大幅な修正が発生する可能性は普通にあります。現在のコードベースを捨てる判断があり得る以上、移行コストの支払いは慎重に行う必要があると考えており、少なくともしばらくはビジネスの状況を見ながらこのまま開発を続けていく予定です。

We're hiring!

ブルーモではエンジニア、デザイナー、PdMなど様々なポジションで募集を行っています。 興味のある方は是非 カルチャーデック もご覧ください。