💡

Dart3のRecordsを活用して、オブジェクトや集約から効率よくデータを取り出す

2024/06/14に公開

はじめに

初めての投稿です。
今回は、Dart3の新機能であるRecordsについて、基本的な紹介というよりも、オブジェクト指向の設計開発を実践する筆者が感じた使いどころという観点で説明します。
自分自身の思考の整理も含めて長めに書いています。要点が知りたい方は(前置き)を飛ばしてください
なお、筆者はまだエンジニア歴が浅いので、この内容が全面的に正しいとは思っていないです。あくまで参考程度にしていただけると良いと思います

Dart3のRecordsについて(前置き1)

2023年のDart3のリリースによって、新たにRecordsが使えるようになりました。
Recordsの概要については他の方から多くの記事が出ていますのでそちらに任せますが、簡単に言うと複数の値をまとめた無名オブジェクトのようなものです(JavaScriptを使っている人には馴染み深いかと思います)。また、名前なしの書き方は他言語でいうタプルに相当します。

以下がRecordsを使ったメソッドの例です。

get_score
/// あなたのidを入力すると、理系科目のテスト結果が返ってくるかも
({int math, int english, int science}) getYourScore(String id) {
    if (id != 'yours') {
      throw Exception('おま誰?');
    }
    return (math: 90, english: 60, science: 60);
}

型定義(上のメソッドの返り値)は

type
({int math, int english, int science})

で、値は

value
(math: 90, english: 60, science: 60)

のように書きます。
もちろん、FutureやListなんかの型ジェネリクスでも使えます。

type_generics
/// サーバーに問い合わせた後、テスト結果が返ってくるイメージ
Future<({int math, int english, int science})>
/// 今年度のテスト結果が一覧で返ってくるイメージ
List<({int math, int english, int science})>

Dart2までを含む今までの多くの言語では返り値は1つだったので、複数のデータ(構造データ)を返す場合は基本的にクラスを作成していたかと思います。しかし、返り値1つのために毎度毎度クラスを作っているとクラスだらけになっていて大変かと思います。その点、Recordsはクラス定義することなく構造データを扱えて便利ですね。
また、Recordsを使ってデータを返しても型チェックはちゃんと働くので、オブジェクト同等の扱いやすさです。

オブジェクト指向(OOP)の「集約」について(前置き2)

Dartを含むOOP言語でコーディングしていると、しばしば「集約(aggregation)」の関係のオブジェクトを作ることがあります。
例えば、学校のデータ管理のシステムを作っていて、「クラスルーム(ClassRoom)」と「生徒(Student)」のオブジェクトを作ると、こんな感じのクラス設計になるかと思います。

student
class Student {
  String id; // 学籍番号(0から始まらないならintでも良い)
  String name; // 名前
  String age; // 年齢
  String phone; // 電話番号
  String mail; // メアド

  Student(this.id, this.name, this.phone, this.mail);

 /// 挨拶する
  void greeting() {
    print('Hi, my name is $name.');
  }
}
class_room
class Classroom {
  String name; // 名前(たんぽぽ組、とか)
 int capacity; // クラスの収容人数
  List<Student> students; // 1クラスに普通は0~複数の生徒がいるはず

  Classroom(this.name, this.capacity, this.students);
}

このクラスルームと生徒の例において「集約」となるのは1つのClassRoomに0~n人のStudentがいるという関係性です。

集約は親が責任を持つルール(前置き3)

オブジェクト指向で集約の関係となるクラス群を作成したとき、基本的に集約内のオブジェクトは、すべて親(Aggregate route)オブジェクトを介して取り扱われるべきというルールを持ってコーディングするべきです。
上記の例でいえば、「Studentの面倒はすべてClassRoomが責任を持つ」ということですね。
以下が、集約のルールを守ってStudentの管理をしているClassRoomのメソッドになります。

class_room
class Classroom {
  String _name; // Dartでは_(アンダースコア)をつけると外部アクセスを不許可に出来る
 int _capacity;
  List<Student> _students;

  Classroom(this._name, this._capacity, this._students);

 /// 生徒全員にひとりずつ挨拶をさせる
  void introduceAllStudents() {
    print('Classroom $_name has the following students:');
    for (var student in _students) {
      student.introduce();
    }
  }

  /// 転入生を受け入れる
  void transferIn(Student student) {
    // キャパ的に受入可能かどうかを調べる
    final isFull = _students.length == _capacity;
    if (isFull) {
     throw Error('これ以上は生徒を入れられないよ!');
    }
    students.add(student);
  }

  /// 転校生を送り出す
 void transferOut(String id) {
    return _students.removeWhere((student) => student.id == id);
  }
}

なぜこのようにコーディングするべきなのかというと、外部からアクセス出来ることによって不都合を生じさせないためです。
理解が難しい内容なので、視点の異なる3つの例を挙げます。

  • 情報漏えい
cracker
List<Stirng> crackInformation(Classroom classRoom) {
    // 変数に_(アンダースコア)がついてないと、データが外からアクセス可能に
    final nameList = classRoom.students.map((student => students.mail));
    // ご丁寧に全生徒のメールアドレスを削除...
    for (var student in classRoom.students) {
      student.mail = '';
    }
    return nameList;
}

外から集約オブジェクト内部のデータにアクセスできてしまうと、あずかり知らぬ場所でデータが使われたり加工されたりしてしまいます。

  • 生徒がさらわれた
suspicious_person
void kidnapping(Classroom classRoom) {
    classRoom.students.removeAt(0); // classRoomの知りえない場所で生徒が削除される
}

この例では、クラスが気づかない間に生徒が消えることになります。誘拐、と行かなくとも転出時に何か手続きがあった可能性もあったのに、それを無視してしまいます。

  • キャパオーバー
headmaster
void admissionThroughConnections(Classroom classRoom, Student student) {
    classRoom.students.add(student); // classRoomのキャパを無視してねじ込もうとした
}

この例では、ClassRoomの収容人数が決められているのに、それ以上に生徒数が入ってしまいます。
ちなみにClassRoomで収容人数マックスの判定を
final isFull = _students.length == _capacity;
のようにしていたことを思い出してください。一度キャパオーバーすると、これ以降は無限に生徒が入ってきてしまいますね。

このように、集約というのは親のオブジェクトが責任を持って内部のオブジェクトすべての取り扱いをすることで、想定外の実装を防ぐことを前提とした概念なのです。
従って、集約の親オブジェクトでは、基本的にメンバ変数はプライベート化(アンダースコアをつける)し、メソッドおよび適切なゲッターを用意するべきです。

集約の中身のデータをどうやって取り出す?

ここまで長かったですが、本題です。
今回自分がDartで実装をする中で、集約の子オブジェクトのデータを都合よく外部から呼び出す効率的な方法はないかという疑問が出ました。

例えば、先ほどの例でクラスの生徒の名簿を作ります。もちろん、Studentの情報はClassRoomが責任をもって取り扱います。

class_room
class Classroom {
 ・・・

 /// getを使うと簡単にゲッターを定義できる
  List<String> get nameList => List.unmodifiable(_students.map((student) => student.name));

 ・・・
}

この場合は、ゲッターの返り値をList<String>の配列にすればいいですね。

では、今度は学籍番号・名前・年齢の3つのデータを名簿にしたい場合はどうでしょうか?

class_room
List<???> get nameList => List.unmodifiable(_students.map((student) => ???));

返り値に困りました。ここで、筆者が思いつく限りのアイデアを書いてみます

  1. Studentをそのまま返す
    一番簡単な方法ですね。
class_room
List<Student> get studentList => List.unmodifiable(_students);

しかし、これでは呼び出し側で名簿に不要(というか載ってはいけない)データが混じります。

list
void useList(Classroom classRoom) {
    final list = classRoom.studentList;
    print(list[0].phone); // 電話番号が漏えいした
}
  1. データ読み取り専用オブジェクト(Data Transfer Object: DTO)を作る
    オブジェクト指向に厳格に行きましょう。
student_data
class StudentData {
  String id;
  String name;
  String phone;

  StudentData(this.id, this.name, this.phone);
}

呼び出すときは、StudentDataに必要なデータを詰めなおしてから返します。

class_room
List<StudentData> get studentDataList =>
List.unmodifiable(
    _students.map(
        (student) => StudentData(student.id, student.name, student.phone)
    )
);

これは非常に安全かつ丁寧な書き方だと思います。メソッドの数だけクラスが量産されることに目を瞑ればですが。。。
自分一人で開発するならともかく、グループ開発になるとこのようなクラス作成のルールを周知徹底するのも、新メンバーがプロジェクトを見通してドメインを理解するのも難しくなるかと思います。
あるいは、いっそ必要なメンバ変数毎にリストを作りますか、、、

Recordsを使ったアイデア

この悩みを抱えていたところに、僥倖というべきか、Dart3でのRecordsだったのです。それまで、筆者はRecordsについて知っていましたが、あまり使う機会がないものと考えていました。
今回、「複数のデータを抽出して返り値としたい」という困りごとに対し、Recordsは非常にマッチしていると思います。
先ほどの例をRecordsを使って書きます。

class_room
List<({String id, String name, int age})> get studentList =>
List.unmodifiable(
    _students.map(
        (student) => (id: student.id, name: student.name, age: student.age)
    )
);

...これだけです!実装者もクラスを追加する手間もなく、また、開発チームのメンバーもこのゲッターの返り値を見ればなんのデータが格納されているのか一目でわかります。

この名簿の値を使うときは以下のようになります。

list
void useList(Classroom classRoom) {
    final list = classRoom.studentList;
    // 例えばFlutterのListViewBuilder内とかで
    list.map((info) => {
        final (:id, :name, :age) = info // 分割代入を使えば一発で全部定義可能
    });
}

Recordsを用いると分割代入を用いて効率よくデータを取り出すことが可能になるのも大きなメリットです。JavaScriptに慣れている人だと分割代入をたくさん使うと思うので嬉しいのではないでしょうか。
このように、Recordsを使うことで開発の効率と保守性の双方にメリットがあることがわかるかと思います。

まとめ

  • Dart3で登場したRecordsによって、複数のデータをクラス作成無しで扱うことが出来るようになりました。
  • オブジェクトや集約の内部のデータを都合よくカプセル化したまま配列(List)で返す際、Recordsを使うと非常に効率よく書けると筆者は考えています(ベストかどうかはプロジェクトやチームの状況次第だと思います)。
  • Recordsを使うことで、データを格納するためだけのクラスが大量に生成されてコードの見通しが悪くなることを防ぐこともRecordsを用いるメリットの1つと考えています。

参考文献

Discussion