気づいたら4ヶ月も経ってたけど、OOC2024の動画を見て思ったことの続き。
関数型DDD
今回の発表で関数型に関した発表がいくつかあった:
関数型の考え方自体は局所的に使えることもあるので知っておいて損はないと思うけど、オブジェクト指向で知られたテクニックを使えばもっとよくなるところを、関数型の考え方に縛られてしまってツラい設計になってるんじゃないかというのが思うところ。 そのあたりを少し話していきたい。
代数的データ型
関数型の話でいつも出てくるのが代数的データ型。
これ、関数型特有の言語仕様と思われやすいけど、ぶっちゃけた話、直積型はC言語でいう構造体、直和型はC言語でいう共用体なので、命令型でも昔からあるデータ構造だったりする。 さらにいうと、共用体は別用途のデータ構造変換ではいまだに使われることがあるけど、インタフェースに対してプログラミングするテクニック(これは関数ポインタを使うので、「関数をオブジェクトとして扱う」という意味で関数型のアプローチに近い)が使われるようになって、ほとんど使われなくなったという歴史もあったり。 抽象構文木のように、木構造の各ノードの型の違いにまで気を付ける必要があるケースを除いて、代数的データ構造が使えないと困るというケースはほとんどない。 ただ、このインタフェースに対してプログラミングするというのを知らないと、かなり濫用されているイメージ。
たとえば、図形の面積を求めるプログラムを考えてみる。
代数的データ型を使ったアプローチをC言語やPythonで書くと、次のような感じになる:
#include <stdio.h> #include <math.h> // 円 typedef struct { float radius; } circle_t; // 長方形 typedef struct { float width; float height; } rectangle_t; // 図形の種類 typedef enum { CIRCLE, RECTANGLE, } shape_kind_t; // 直和型で複数の型を内包する型を表現する typedef struct { shape_kind_t kind; // 型の情報を持つ union { // 共用体は複数のデータ構造を保持できる(同じメモリ領域が使われる) circle_t circle; rectangle_t rectangle; } data; } shape_t; // 面積の計算 float calc_area(shape_t* shape) { float area = 0.0; float radius = 0.0; float width = 0.0; float height = 0.0; // データ構造に応じて場合分け(直和型のアプローチ) switch (shape->kind) { case CIRCLE: radius = shape->data.circle.radius; area = radius * radius * M_PI; break; case RECTANGLE: width = shape->data.rectangle.width; height = shape->data.rectangle.height; area = width * height; break; } return area; } int main(void) { // 円の生成 shape_t circle = { .kind = CIRCLE, .data = {.circle = {.radius = 10.0}}, }; // 長方形の生成 shape_t rectangle = { .kind = RECTANGLE, .data = {.rectangle = {.width = 10.0, .height = 12.0}}, }; // 円の面積 printf("circle: %f\n", calc_area(&circle)); // 長方形の面積 printf("rectangle: %f\n", calc_area(&rectangle)); return 0; }
from dataclasses import dataclass from typing import Union import math # 円 @dataclass(frozen=True) class Circle: radius: float # 長方形 @dataclass(frozen=True) class Rectangle: width: float height: float # 図形 Shape = Union[Circle, Rectangle] # 面積の計算 def calc_area(shape: Shape) -> float: # データ構造に応じて場合分け(直和型のアプローチ) if isinstance(shape, Circle): return shape.radius * shape.radius * math.pi if isinstance(shape, Rectangle): return shape.width * shape.height raise ValueError("type is invalid.") # 円の生成 circle = Circle(10.0) # 長方形の生成 rectangle = Rectangle(10.0, 12.0) # 円の面積 print(f"circle: {calc_area(circle)}") # 長方形の面積 print(f"rectangle: {calc_area(rectangle)}")
データ構造を複数用意して、そのORを受け付ける関数を用意し、型に応じて処理を切り分けるとなっている。 言語の違いで書き方に多少の差はあっても、慣れ親しんだ書き方という人は多いと思う。
ただ、この書き方は、扱うデータ構造が増減したときに、switch文を使っているところを全部書き直す必要があるという問題がある。 つまり、データ構造を扱うロジックがあちこちに散らばっているという問題がある。 これは変更が大変だし、変更の影響を調べるのも大変。
発表では、型でエラーが検出されるからいいと言っていたけど、正直いろんなファイルに渡って修正をしないといけないのはかなりツラい。
そこで、インタフェースに対してプログラミングするテクニックが生まれた。 これは関数をオブジェクトとして扱うことになるので、実はこの方が関数型的な書き方とも言える(C言語の方を見ると分かりやすいかも)。
インタフェースを使ったコードをC言語とPythonで書くと、次のようになる:
#include <stdio.h> #include <math.h> // 面積を計算する関数(のポインタ)を型として表現 typedef float (*calc_area_t)(void* data); // 図形は面積を計算できる typedef struct { calc_area_t calc_area; // 関数をデータとして扱う } shape_t; // 面積の計算 float calc_area(shape_t* shape) { return shape->calc_area(shape); } // 円に関する定義 ---------- // データ構造 typedef struct { shape_t base; // 図形のインタフェースを継承 float radius; } circle_t; // 面積の計算 float circle_calc_area(void* data) { circle_t* circle = (circle_t*)data; float radius = circle->radius; return radius * radius * M_PI; } // データの初期化 void circle_init(circle_t* circle, float radius) { circle->base.calc_area = circle_calc_area; // 使う関数をデータとして渡しておく circle->radius = radius; } // 長方形に関する定義 ---------- // データ構造 typedef struct { shape_t base; // 図形のインタフェースを継承 float width; float height; } rectangle_t; // 面積の計算 float rectangle_calc_area(void* data) { rectangle_t* rectangle = (rectangle_t*)data; float width = rectangle->width; float height = rectangle->height; return width * height; } // データの初期化 void rectangle_init(rectangle_t* rectangle, float width, float height) { rectangle->base.calc_area = rectangle_calc_area; // 使う関数をデータとして渡しておく rectangle->width = width; rectangle->height = height; } int main(void) { // 円の生成 circle_t circle; circle_init(&circle, 10.0); // 長方形の生成 rectangle_t rectangle; rectangle_init(&rectangle, 10.0, 12.0); // 円の面積 printf("circle: %f\n", calc_area(&circle.base)); // 長方形の面積 printf("rectangle: %f\n", calc_area(&rectangle.base)); return 0; }
from dataclasses import dataclass from typing import Protocol import math # 図形は面積を計算できることをインタフェースで表現 class Shape(Protocol): def calc_area(self) -> float: ... # 円 @dataclass(frozen=True) class Circle(Shape): radius: float def calc_area(self) -> float: return self.radius * self.radius * math.pi # 長方形 @dataclass(frozen=True) class Rectangle(Shape): width: float height: float def calc_area(self) -> float: return self.width * self.height # 円の生成 circle = Circle(10.0) # 長方形の生成 rectangle = Rectangle(10.0, 12.0) # 円の面積 print(f"circle: {circle.calc_area()}") # 長方形の面積 print(f"rectangle: {rectangle.calc_area()}")
こう書くと、データ構造ごとにswitchする構造がなくなっているのが分かるかと思う。 また、各データ構造に対する処理がまとまっているのも分かるかと思う。 このおかげでロジックの確認をするときにそのデータ構造を使っている場所を全部探して確認するという手間が省ける。 また、データ構造が増減したとしても、そのデータ構造に関する部分だけ直せばいいので、修正の範囲が限定されて扱いやすくなる。
興味深いのは、上記の仕組みを実現するために関数をデータとして扱っているということ。 これは関数型でいう高階関数を使っているのに相当する。 さらには関数で使うデータをメンバとして保持しているわけだけど、これは関数の部分適用を行った状態に相当する。
だから、オブジェクト指向のオブジェクトを関数型的に捉えると、部分適用を行なった高階関数の集まりとも言えて、そういう意味で関数型を進めた先にあるのも実はオブジェクト指向に近いものになる。 Haskellの型クラスとかまさにそうだし。
そして、それを愚直に実装したのがC言語のコードだけど、これを自力で実装できるようになるのは大変で、それを書きやすくしてるのがクラスの機能だったりする。 Pythonのコードを見るとC言語のコードに比べてすごくスッキリしているのが分かるかと思う。 これで内部ではC言語と同じように関数をデータとして扱ってたりするんだけど、表面ではそれを意識せずに使うことができる。
その辺りを理解せずに「いまどきオブジェクト指向はダメ、時代は関数型」とか言いながら代数的データ構造でちまちまswitchをやってるようだと、オブジェクト指向としても関数型としても理解が浅いところがある。 そして、関数型でもそれなりに苦労して書かないといけないところを、オブジェクト指向なら比較的簡単に書けるので、わざわざ関数型で書かなくてもなぁとなってくるんよねぇ。
結局のところ、具体的なデータ構造を見てプログラミングしているのだと上記のようになってしまうので、『オブジェクト・ウォーズ』に書いたように、「データを軽視し、インタフェースを導入せよ」という、インタフェースを重視するステップに進む必要がある。
まだ書きたいことはあるんだけど、長くなったので一旦区切り。
今日はここまで!