値オブジェクト実装パターン:KotlinとGoで見る設計の違い

はじめに
ログラスに新卒入社後にKotlinとGoの両方で値オブジェクトを実装する機会がありました。
同じ(あるいはよく似た)値オブジェクトをそれぞれの言語で表現してみると、言語仕様や慣習の違いがそのまま実装方針の違いとして現れ、複数言語をまたいだからこそ気づけたポイントがいくつかありました。この記事では値オブジェクトの実装に焦点を当てKotlinとGoそれぞれの実装パターンを比較していきます。
値オブジェクトの3つの特徴
値オブジェクトは「同一性(identity)ではなく値(value)で識別される」オブジェクトです。
実装で押さえるべき特徴は以下の3つになります[1]。
- 不変である
- 交換が可能である
- 等価性によって比較される
1. 不変である
値オブジェクトは一度生成されたら内部状態が変更されないことが重要です。不変性により、予期しない副作用を防ぎ、複数のコンテキストで安全に共有できます。
Kotlin
Kotlinでは以下の言語機能により不変性を表現できます
-
private constructorで不正な生成をコンパイルレベルで防ぐ -
valでフィールドの再代入を禁止(ただし、MutableListなど可変コレクションの内容変更は防げないため注意)
完全な不変性の実現には、可変コレクションを避け、メソッドで新しいインスタンスを返すなどの実装上の配慮が必要です。
// 価格を表す値オブジェクト
data class Price private constructor(
val amount: Long, // 価格(最小単位:円やセント)
val currency: String // 通貨コード("JPY", "USD" など)
) {
companion object {
fun create(amount: Long, currency: String): Price {
require(amount >= 0) { "価格は0以上である必要があります" }
require(currency.isNotBlank()) { "通貨コードが空です" }
return Price(amount, currency)
}
}
fun add(other: Price): Price {
require(currency == other.currency) { "異なる通貨の価格は計算できません" }
return create(amount + other.amount, currency)
}
}
Go
Goでは以下の言語機能により不変性を表現できます
- フィールドを非公開(小文字)にして外部からの変更を防ぐ
Goでは完全な不変性は言語仕様上保証されないが、設計パターン(コンストラクタ関数のみで生成し、Setterを作らない)で擬似的に変更が難しい構造にすること可能です。また、同一パッケージ内からの変更は防げないため、規約による制御も重要になります。
package domain
import "errors"
type Price struct {
amount int64
currency string
}
func NewPrice(amount int64, currency string) (Price, error) {
if amount < 0 {
return Price{}, errors.New("価格は0以上である必要があります")
}
if currency == "" {
return Price{}, errors.New("通貨コードが空です")
}
return Price{amount: amount, currency: currency}, nil
}
func (p Price) Amount() int64 { return p.amount }
func (p Price) Currency() string { return p.currency }
func (p Price) Add(other Price) (Price, error) {
if p.currency != other.currency {
return Price{}, errors.New("異なる通貨の価格は計算できません")
}
return Price{amount: p.amount + other.amount, currency: p.currency}, nil
}
不変性の比較
- Kotlin:言語機能で「生成経路の統制」+「再代入禁止」を強制しやすい
-
Go:外部からの変更は防げるが、同一パッケージ内は規約に依存しやすい(境界設計が重要)
- 注意:Goでは同一パッケージ内の別ファイルから小文字フィールドも変更可能
2. 交換が可能である
値オブジェクトは不変であるため、値の変更が必要な場合でも内部状態を直接変更することはできません。そのため、代入操作による交換以外の手段で変更を表現することが難しく、新しいインスタンスを生成して差し替えるという方法を取ります。
Kotlin
val priceA = Price.create(100, "JPY")
val priceB = Price.create(100, "JPY")
// 同じ値なら、どちらを使っても意味は同じ
println(priceA == priceB) // true
// 値を変更したい場合は、新しいインスタンスを生成する
val priceC = priceA.add(Price.create(50, "JPY"))
println(priceC.amount) // 150
println(priceA.amount) // 100(元のインスタンスは変わらない)
Go
priceA, _ := NewPrice(100, "JPY")
priceB, _ := NewPrice(100, "JPY")
// 同じ値なら、どちらを使っても意味は同じ
fmt.Println(priceA == priceB) // true
// 値を変更したい場合は、新しいインスタンスを生成する
addition, _ := NewPrice(50, "JPY")
priceC, _ := priceA.Add(addition)
fmt.Println(priceC.Amount()) // 150
fmt.Println(priceA.Amount()) // 100(元のインスタンスは変わらない)
交換可能性の比較
- Kotlin:不変性が言語機能でサポートされ、同じ値なら交換可能という性質を自然に実現
- Go:値型として扱うことで交換可能性を実現、ポインタを避けることで不変性を保証
3. 等価性によって比較される
値オブジェクトは参照同一性(同じインスタンスかどうか)ではなく、値の同一性(保持している値が同じかどうか)で比較されます。つまり、別々に生成したインスタンスでも、同じ値を持っていれば等価として扱われることが重要です。
Kotlin
-
a == bが 値としての同一性を表す -
hashCodeも値ベースなのでSetやMapのキーにも使用可能
ただし、Kotlinの通常 class は equals を実装しない限り参照同一性で比較されてしまいます。値オブジェクトとしては data class を基本形にする方が安全です。
data class を使用すると値オブジェクトの等価性を満たすことが容易になります。
data class CustomerName private constructor(
val firstName: String,
val lastName: String
) {
companion object {
fun create(firstName: String, lastName: String): CustomerName {
// バリデーションの省略
return CustomerName(firstName, lastName)
}
}
}
val a = CustomerName.create("太郎", "田中")
val b = CustomerName.create("太郎", "田中")
println(a == b) // true(両方のフィールドが一致)
Kotlinのdata classはList、Set、Mapなどのコレクションを含んでいても自動的に深い比較(deep equality)が可能です。ただし、Arrayを含む場合は注意が必要です。data classの自動生成されるequalsはArrayに対して参照比較を行うため、内容が同じでもfalseになります。Arrayの内容比較が必要な場合は、equalsをオーバーライドしてcontentEqualsを使用する必要があります。
// List、Set、Mapは問題なく深い比較が可能
data class Tags(val values: List<String>)
val a = Tags(listOf("tag1", "tag2"))
val b = Tags(listOf("tag1", "tag2"))
println(a == b) // true(中身を比較)
// Arrayを含む場合は要注意
data class Matrix(val values: Array<Int>)
val m1 = Matrix(arrayOf(1, 2, 3))
val m2 = Matrix(arrayOf(1, 2, 3))
println(m1 == m2) // false(参照比較になる)
Go
Goでは、基本型(string、int、boolなど)のみで構成されたstructは == で比較できます。
type CustomerName struct {
firstName string
lastName string
}
func NewCustomerName(firstName, lastName string) (CustomerName, error) {
// バリデーションの省略
return CustomerName{firstName: firstName, lastName: lastName}, nil
}
a, _ := NewCustomerName("太郎", "田中")
b, _ := NewCustomerName("太郎", "田中")
// ※本来はエラーハンドリングが必要だが省略
fmt.Println(a == b) // true(両方のフィールドが一致)
ただし、Goのslice、map、function等を含む構造体は == 演算子での比較ができず、コンパイルエラーになります。その場合は手動でEqualsメソッドを実装する必要があります。
type Tags struct {
values []string // sliceを含む
}
func NewTags(values []string) Tags {
return Tags{values: values}
}
a := NewTags([]string{"tag1", "tag2"})
b := NewTags([]string{"tag1", "tag2"})
// 手動でEqualsメソッドを実装する必要がある
func (t Tags) Equals(other Tags) bool {
if len(t.values) != len(other.values) {
return false
}
for i, v := range t.values {
if v != other.values[i] {
return false
}
}
return true
}
なお、Goにはreflect.DeepEqualを使って比較する方法もありますが、パフォーマンスが悪く型安全性も失われるため、本番環境では手動でのEqualsメソッド実装が選ばれることが多いです。
この違いは、複雑な構造を持つ値オブジェクトを実装する際に大きな差となります。
等価性の比較
-
Kotlin:
data classを用いることでList/Set/Mapなどは深い比較が容易(ただしArrayは要注意) -
Go:基本型のみのstructなら
==で比較可能。slice/mapを含む場合は手動でEquals実装が必要
まとめ
KotlinとGoで同じ値オブジェクトを実装してみて、言語機能の違いが設計アプローチに影響することを実感しました。
Kotlinはdata classにより不変性や等価性を言語レベルで表現しやすく、private constructorやcompanion objectで生成ロジックをカプセル化できます。
一方Goはクラスを持たず、構造体とメソッドの組み合わせで値オブジェクトを表現します。シンプルさゆえの見通しの良さがある反面、等価性の実装やバリデーションの強制は、パッケージ設計や命名規約でカバーする必要があります。
オブジェクト指向的アプローチと手続き的アプローチでここまで実装が変わることを理解できました。両方触ってみると、それぞれの言語設計の思想が見えてきて、エンジニアとしての引き出しが増えた気がします。
Discussion