はじめに
本記事は The Mutability Tax をベースにしています。
意訳・抜粋しまくったので翻訳記事と呼ぶには忍びないですが、記述の足らない箇所があれば元の記事を参照してください。
筆者の David Morgan 氏はGoogleのソフトウェアエンジニアです。
元記事の公開は2019年7月15日です。
本文中に登場するコードは Dart
で記述されています。
The Mutability Tax では、MutableとImmutableそれぞれの設計によって生じるコードメンテナンスコストのことを Tax(税金) と形容しています。
本記事では 代償 と表現します。
3点要約
- Mutableな型はバグを生みやすいです。
- Immutableな型も正しく扱わないとコードが肥大化してバグを生みやすく、遅くなります。
- コード生成(freezed)の力を借りて、簡単に安全な型を定義しましょう。
Mutable(値が変更可能)
システム間でMutableなデータを渡す疎結合モジュールを持つシステムは、Mutability-Taxという名のメンテナンスコストを支払います。
### 🚨 Mutableの代償①:予期せぬ値の変更上司 👹 : おい新人。世界の都市圏人工TOP5のランキングと、その中で最大の都市を表示するコードを書いてみろ。ランキングは以下の通りやで。
1位:Tokyo
2位:Jakarta
3位:Delhi
4位:Manila
5位:Seoul
新人 😀 : はい!わかりました!
上司 👹 : こういう感じで出力されればOKやで。この中で最大の都市は東京やからな。
// Output
アルファベット順に表示:
Delhi, Jakarta, Manila, Seoul, Tokyo
この中で最も大きいのは Tokyo です。
新人 😀 : [ Dart List アルファベット順に表示 🔍 ] でググって…
void display(List<String> strings) {
(strings..sort()).forEach(print);
}
新人 😀 : あとは上司に指定されたリストを使って表示すればいけるやろ!
var biggestCitiesRanking = ['Tokyo', 'Jakarta', 'Delhi', 'Manila', 'Seoul'];
print('アルファベット順に表示: ');
DisplayAlphabetically(biggestCitiesRanking);
print('この中で最も大きいのは ${biggestCitiesRanking.first} です。');
biggestCitiesRanking
の first
を指定しているのですから、最大の都市である Tokyo
が出力されて欲しいです。
アルファベット順に表示:
Delhi, Jakarta, Manila, Seoul, Tokyo
この中で最も大きいのは Delhi です。
新人 😀 : なんでや!Tokyoが最大のはずやのになんでDelhiなんや!
どうやら DisplayAlphabetically().display()
を実行することで biggestCitiesRanking
が勝手に書き換えられているようです。
例えば上のような実装がされていると、本来は 表示するだけ の関数を実装したつもりが、 勝手にリストの中身を変更 してしまい 、結果間違った値を表示したというわけです。
これは DisplayAlphabetically().display
を書いた人間が悪いという話ではありません。このような 予期せぬ値の変更 を行わないことを人間に徹底させること自体が非常にナンセンスです。
そしてこの問題への最適な取り組み方は、 biggestCitiesRanking
を 変更不可能にする(Immutable) にするということです。
上司 👹 : おいドアホ!ちゃんとImmutableにしとかんかい!
Immutable(値が変更不可能)
オブジェクト指向言語では、Immutabilityを人力で管理しようとすることでバグ、パフォーマンス上のリスクを発生させ、Immutability-Taxという名のメンテナンスコストを支払います。
### 🚨 Immutableの代償①:巨大なクラスMutableなオブジェクトの受け渡しでは、オブジェクトが変更されたあとのことをアレコレ考える必要があります。
特にViewの実装や並列処理を行うようなコードにおいては、Mutableなオブジェクトの状態を推論することはとても難しく、莫大なメンテナンスコストと重大なバグを発生させる原因となります。
さきほどの例におけるオブジェクトの受け渡しはたった2個のモジュールの間に発生していましたが、モジュールが大量にある状態ではどうでしょうか。
モジュールの組み合わせの数は nCr
で計算されるように爆発的に増加し、組み合わせの数だけコストが増えていきます。
残念ながら、ほとんどのオブジェクト指向の言語は Mutableの代償(Mutability-Tax) を考慮して設計されていません。そのためにプログラマーはImmutableに関するルールやアンチパターンを勉強し、ライブラリを入れて構築しなければなりません。
しかし人間の頭に頼っていては、いつかは落とし穴にハマってしまいます。
落とし穴をもう少し深掘ってみます。
上司 👹 : おい新人、顧客管理システムを想定したカスタマーデータのクラスを作ってみろ。
新人 😀 : わかりました!
上司 👹 : 名前をString、年齢をintで定義するところまではええな。
新人 😀 : はい!
上司 👹 : 次に positional parameters (位置指定パラメーター)
か named parameters (名前指定パラメーター)
のどちらを採用するか、という議論では、のちにパラメーターが増えたときの可読性をあげるために後者を選択するで。
新人 😀 : なるほど!それは賢いですね!
class Customer {
final String name;
final int age;
Customer({this.name, this.age});
}
上司 👹 : 次に新しいカスタマー情報を更新する場合を考えるで。年齢を+1するコードを書いてみろ。
var customer = Customer(name: 'John Smith', age: 34);
var updatedCustomer = Customer(
name: customer.name, age: customer.age + 1);
新人 😀 : こんな感じですかね?
上司 👹 : そうや。例えばマーケチームから、顧客が何回サイトを訪れたかを記録したいと言われたときを考えてみろ。
新人 😀 : わかりました!じゃあ visits
パラメーターを追加すればいいですね!仮に12回訪問している場合こんな感じですかね…
var customer = Customer(
name: 'John Smith', age: 34, visits: 12);
上司 👹 : じゃあさっき書いた、年齢を+1するコードを書いてみろ。
新人 😀 : コピペでぽいっと
var customer = Customer(
name: 'John Smith', age: 34, visits: 12);
var updatedCustomer = Customer(
name: customer.name, age: customer.age + 1);
上司 👹 : おいこら、そんなことしたら visits
がリセットされるやないか!
新人 😀 : うわ〜
上記のコードでは visits
の値がリセットされてしまいます。
ここでの対応としては、 positional parameters
を採用するか、 @required
アノテーションで全てのパラメーターを必須とすることが考えられます。
しかし前述の通り位置指定パラメーターは可読性を落としますし、@required
を全てに付与するということは、例えばカスタマーが訪れたときに visits
に1を加算する処理においても全てのパラメーターを指定する必要があります。
上司 👹 : これをメソッドで書くとこんな感じになるやろ?
class Customer {
final String name;
final int age;
Customer({this.name, this.age});
Customer copyWith({String name, String age}) =>
Customer(name: name ?? this.name, age: age ?? this.age);
}
新人 😀 : 完璧っすね!
var updatedCustomer = customer.copyWith(age: customer.visit + 1);
上司 👹 : 甘いねん。Dartは null
に弱いねん。このメソッドやと visit
に null
が渡されたことを認識できひんねん。
// nullは認識されないため、このコードは動きません
var customerWithoutVisits = customer.copyWith(visits: null);
上司 👹 : Nullを認識させるためにはな、それぞれのフィールドに with
メソッドを使ったら一応イケるねん。
class Customer{
final String name;
final int age;
final int visits;
Customer({this.name, this.age, this.visits});
Customer withName(String name)
=> Customer(name: name, age: age, visits: visits);
Customer withAge(int age)
=> Customer(name: name, age: age, visits: visits);
Customer withVisits(int visits)
=> Customer(name: name, age: age, visits: visits);
}
新人 😀 : フィールドがリセットされることも無いし、Nullにも対応したし、今度こそ完璧じゃないですか!
上司 👹 : お前な、実際のサービス考えてみい。Customer
が持つフィールド何個あると思てんねん。余裕で10個以上あるわ。
新人 😀 : 確かに…。その分だけ with
を書いて、その分だけ引数を渡すことになりますね。
上司 👹 : そんなデカいボイラープレートで運用してたらお前絶対ミスるやろ。何よりクソ重なるわ。
新人 😀 : はい…
🚨 Immutableの代償②:ネストされた型への対応
上司 👹 : コレクションと、ネストされた型が使用される場合を考えてみろ。
Mutable(変更可能)なコレクションをImmutable(変更不可能)なオブジェクトに組み込む場合には、それらを安全にコピーする必要があります。
またImmutableな型を使用する利点は高速であることですが、遅くなってしまうことが考えられます。
そして、先ほどのように with
メソッドをしてしまうとネストされたフィールドの扱いが面倒になってきます。
class ShoppingBasket {
final Customer customer;
final List<Item> items;
final List<Offer> offers;
ShoppingBasket(
this.customer,
Iterable<Item> items,
Iterable<Offer> offers)
// Copy defensively to ensure immutability.
: this.items = List.unmodifiable(items),
this.offers = List.unmodifiable(offers);
// TODO: add "with" method per field.
}
上司 👹 : 例えば上のECサイトのカート(ShoppingBasket)に商品を追加する場合、 with
を使ってこう書けるよな。
var updatedBasket = basket
.withCustomer(basket.customer.withName(updatedName))
.withItems([...basket.items, newItem, newItem2]);
新人 😀 : でもこんなのいちいち with
でコピーしてたら遅くなりませんか…?
上司 👹 : …その通りや。Immutableは高速であることが利点やのに、遅くしてどないすんねんっちゅう話や。
上司 👹 : 追加に関しては add
で書くこともできるけどな、まあこれも同じような話や。
var updatedBasket = basket
.withCustomerName(updatedName)
.addItem(newItem)
.addItem(item2);
新人 😀 : なんかまたボイラープレートが大きくなりそうですね。
上司 👹 : せやな。その上毎回フルコピーが実行されるから遅いしな。
結局何が実現されていれば良いのか
ここまで問答を繰り返してきましたが、理想としては以下の4点が実現されると良さそうです。
1. Immutableなデータが提供され
2. 更新が簡単かつ高速で
3. Nullを扱うことができ
4. フィールドが追加されても既存のコードに影響を与えない
上司 👹 : 結論としては、ボイラープレートをコード生成するライブラリを使えっちゅう話や
新人 😀 : 例えばどんなのがあるんですか?
上司 👹 : Dartの独自のライブラリやと Built Value
やな。
Built Value
※現在はこれに代わってfreezedの使用が推奨されています
特徴
- Immutableな型 (コレクションは built_collection)
- EnumClass(enumsのような機能)
- JSONのシリアル化
上司 👹 : ボイラープレートの管理は本来コストがかかるしバグを生みやすいよな。でもコード生成の場合はそのコストがかかるのは最初だけやねん。
新人 😀 : なるほど…Mutableの代償みたいに税金みたいに段々重くなるコストじゃなくて入場料を払えばOKって感じですね!
上司 👹 : お前もわかってきたな。 built_value
を使ったらボイラープレートは生成してくれるからな、人間が書くのはこれだけでええねん。
abstract class Customer implements Built<Customer, CustomerBuilder> {
String get name;
int get age;
@nullable
int get visits;
factory Customer(void Function(CustomerBuilder) updates) =
_$Customer;
Customer._();
}
新人 😀 : 基本的なプロパティとアノテーションだけでいいのはありがたいですね!これならエンバグしなさそうです!
上司 👹 : これはJavaでいうところの AutoValue.Builder にあたるな。ほんでその開発チームがコード生成ライブラリの必要性についてわかりやすくまとめたスライド AutoValue: what, why and how? を公開してくれてるわ。
AutoValue: what, why and how? の要約
多くのオブジェクト指向言語では、標準の言語仕様に従って 人力で Immutabilityを実現しようとすると、メンテナンスコスト、バグ、パフォーマンスの問題が発生します。つまりMutableの代償を避けるために、今度はImmutableの代償を払う事態になっています。Immutableオブジェクトのボイラープレートを生成し、管理する ライブラリを利用する ことこそが、Mutabilityへの正しい向き合い方です。
結論、 freezed でコード生成しなさい
上司 👹 : さっきは built_value
を例に上げたけど、最近は state_notifier
とセットで freezed
がよう使われてんねん。
元の記事 の公開後、 built_value
より更に優秀な freezed というライブラリが発表されました。
ここでは簡単に freezed
によって解決される、 built_value
が劣っている点を記しておきます。
- 大量のボイラープレートが必要となります。
-
built_value
はDart1
用に作られており、順次v2に対応しているようですが、所々未対応の部分があるようです。 - コード生成がかなり遅いです。
※ コード生成については freezed
もそれなりに遅いですが、その問題に対してはmonoさんが素晴らしいTipsをtweetしておられました。これには作者であるRemi氏も同意していました。
Clever idea!
— Remi Rousselet (@remi_rousselet) July 18, 2020
Honestly, I wonder if this sort of optimization could be built right into package:build
It's probably analyzing files that don't need to be analyzed
新人 😀 : なるほど、 freezed
使ってみます!
上司 👹 : もう人力でやろうとすんなよ!
P.S.
誤りがありましたらコメントにてご指摘いただけますと幸いです。