PHPで関数型プログラミング!Result型を使ってクリーンで予測可能なコードへ
こんにちは🐈!
みなさん、いろんなところで「関数型はいいぞ」「関数型にせねば」という声をよく耳にしませんか?
今回は関数型プログラミングの簡単な解説と、PHPにおける関数型プログラミングへのアプローチについて書いてみました。
参考にさせていただいたのは、「なっとく!関数型プログラミング」という書籍です。
関数型プログラミングと純粋関数
まずは関数型プログラミングとはなんぞや、について解説します。
書籍によると、関数型プログラミングは以下を満たす関数を使ったプログラミングのことです。
- シグネチャ(関数名、引数の型や数、戻り値の型)が嘘をつかない
- 本体が極力宣言的である
シグネチャが嘘をつかないというのはつまり予測可能性が高く、信頼のできる関数です。
なかでも、純粋関数がもっとも信頼できる関数とされています。
純粋関数とは、以下を満たす関数とのことです(書籍から引用)
- 戻り値は常に1つだけ
- 引数のみに基づいて戻り値を計算する
- 既存の値を変更しない
※「戻り値は常に1つだけ」は戻り値の個数の話ではなく、関数の実行が例外などで中断されることなく必ず何らかの値を返すということを指しているのだと解釈しました。
純粋関数のメリットとは
純粋関数で実装すると、クリーンで予測しやすいコードになります。
具体的には以下のようなメリットがあります!
関心の分離
純粋関数が行うことは、「引数のみから戻り値を計算すること」だけです。
そのためその関数は単一責任となります。
また副作用(引数に基づいて戻り値を計算すること以外の処理全般)がないため、自身のやることのみに集中できます。
これにより、副作用のある処理とは関心を明確に分離することができます。
テストが容易
純粋関数の最大の特徴のひとつに、参照透過性があげられます。
参照透過性とは、関数が同じ入力に対して常に同じ結果を返す性質のことです。
これにより、純粋関数のテストは「引数のパターンとその結果の組み合わせを考えるだけ」で済むため、テストコードの生成が比較的容易になります。
PHPで関数型アプローチを実現する
純粋関数が大きなメリットをもたらすことは分かりましたが、実際のシステムでは副作用のある処理の方が多いです。
DBからデータを取得し、データを更新し、データ登録をする。
これらはすべて副作用のある処理です。
関数型プログラミング言語であるScalaでは、こういった副作用を伴う処理をIO
という型で値として扱い、その実行を遅延させることで、副作用のある処理も純粋関数として扱えるようにしています。
PHPでは副作用のある処理をどう扱えば良いのでしょうか🤔
そこでResult型
PHPでは、Scalaのように副作用のある処理を遅延実行させるような便利な型は実装されていません。
ただし、副作用のある処理、特に失敗する可能性のある処理を安全に扱うためのアプローチは存在します。
そのひとつがResultという型です。
こちらの型はScalaのEither
型に非常に近しい概念です。
ただし標準では実装されていないため、独自で実装する必要があります。
Result
型の詳細はこちらを参照していただければと思いますが、主な特徴だけ説明します。
-
Ok
型(成功) 、Err
型(失敗)の2つの型を持つ -
Ok
には成功時の値を、Err
にはエラー情報を格納することができる
実際に実装してみる
やりたいこと
今回の修正対象のコードの処理は、以下の内容を実現するものです。
変更前のコード
では実際にコードを見ていきましょう。
※以下はLaravelで実装されたコードです
/**
* DBから取得した企業IDと外部システムから取得した企業情報を元に、画面表示用にデータを整形している(非純粋関数)
*/
function getCompanyListWithErrorByPersonResourceId(int $user_id): array
{
// ユーザーIDを元に、DBから企業IDを取得
$companies = $this->getCompanyListByDb($user_id);
// エラーメッセージ(デフォルトは空文字)
$error_message = '';
// ユーザーに紐づく企業が存在しない場合は空の配列を返す
if ($companies->isEmpty()) {
return [
'company_list' => [],
'error_message' => $error_message,
];
}
// 外部システムから企業情報を取得
$external_system_response_data = $this->getCompanyDataFromExternalSystem($companies->pluck('corporate_number'));
if ($external_system_response_data === null) {
// 外部システムからのデータ取得に失敗した場合は、DBから取得したデータのみでリストを作成して返す
$company_view_list = $this->convertToViewModelsOnlyDbData($companies)->toArray();
$error_message = '企業情報取得に失敗しました。';
} else {
$company_view_list = $this->convertToViewModels($companies, $external_system_response_data)->toArray();
}
return [
'company_list' => $company_view_list,
'error_message' => $error_message,
];
}
/**
* DBから企業情報を取得する(非純粋関数)
*/
function getCompanyListByDb(int $user_id): Collection
{
try {
return $this->repository->getListByPersonResourceId($user_id);
} catch (Exception $e) {
// DBからのデータ取得に失敗した場合は例外を投げる
throw new Exception('DB接続に失敗しました。');
}
}
/**
* 外部システムから企業情報を取得(非純粋関数)
*/
function getCompanyDataFromExternalSystem(Collection $corporate_numbers): ?array
{
try {
return $this->getCompaniesByCorporateNumbers($corporate_numbers);
} catch (Exception $e) {
// 失敗時はnullを返却
return null;
}
}
変更前のコードの問題点
変更前のコードに登場するメソッドは、すべて非純粋関数です。
特に、以下の2つの処理に副作用があります。
- DBから企業情報を取得
- 外部システムから企業情報を取得
そして、以下のような問題が潜んでいます。
- DB接続失敗時に、例外を投げている
- 例外を投げると、
try-catch
しない限り処理がそこで止まってしまう - 関数の戻り値は
Collection
と宣言してあるのにも関わらず、例外の場合は何も返却されないため、関数のシグネチャが嘘をついていることになる
- 例外を投げると、
- 外部システム接続失敗時に、
null
を返却している-
null
を返却する場合、呼び出し元でnull
チェック処理を挟む必要があり、そのチェックが漏れるとランタイムエラーの原因になる -
null
が正常パターンなのか異常パターンなのかの判別を呼び出し元では判断しにくく、関数の意図を汲み取りにくい
-
変更後のコード
Result型で副作用をハンドリングしつつ、上記の問題を解消していきます!
まずはResult
型に関する実装内容を見ていきます。
先ほどご紹介した資料を参考に、Result
の抽象クラスをOk
クラスとErr
クラスが継承する形で実装しました。
/**
* Resultクラス
* @template O
* @template E
*/
abstract readonly class Result
{
public abstract function isOk(): bool;
public abstract function isErr(): bool;
public abstract function unwrap(): mixed;
public abstract function unwrapErr(): mixed;
public abstract function flatMap(callable $fn): Result;
}
/**
* Okクラス
* @template O
* @extends Result<O, never>
*/
final readonly class Ok extends Result
{
public function __construct(
private mixed $ok,
) {
}
public function isOk(): bool
{
return true;
}
public function isErr(): bool
{
return false;
}
public function unwrap(): mixed
{
return $this->ok;
}
public function unwrapErr(): never
{
throw new RuntimeException('called Result->unwrapErr() on an ok value');
}
public function flatMap(callable $fn): Result
{
$value = $fn($this->unwrap());
if ($value->isOk()) {
return new Ok($value->unwrap());
} else {
return new Err($value->unwrapErr());
}
}
}
/**
* Errクラス
* @template E
* @extends Result<never, E>
*/
final readonly class Err extends Result
{
/**
* @param E $err
*/
public function __construct(
private mixed $err,
) {
}
public function isOk(): bool
{
return false;
}
public function isErr(): bool
{
return true;
}
public function unwrap(): never
{
throw new RuntimeException('called Result->unwrap() on an err value');
}
public function unwrapErr(): mixed
{
return $this->err;
}
public function flatMap(callable $fn): Result
{
return $this;
}
}
続いて、Result
型を使ったアプリケーションコードを見ていきます。
/**
* DBから取得した企業IDと外部システムから取得した企業情報を元に、画面表示用にデータを整形している(非純粋関数)
*/
function getCompanyListWithErrorByPersonResourceId(int $user_id): array
{
// 企業リストをDBから取得
$db_result = $this->getCompanyListByDb($user_id);
// 法人番号リストを使って外部システムから企業情報を取得
$external_system_result = $db_result
->flatMap(fn(Collection $corporate_number_list) => $this->getEnterpriseMasterResponseData($corporate_number_list))
;
// フロントエンドに返却するためのデータを整形
return $this->formatResult($external_system_result, $db_result);
}
/**
* DBから企業情報を取得する(非純粋関数)
*/
function getCompanyListByDb(int $user_id): Result
{
try {
return new Ok($this->repository->getListByPersonResourceId($user_id));
} catch (Exception $e) {
// DBからのデータ取得に失敗した場合は、エラーメッセージを格納したExceptionをラップしたErrを返す
return new Err(new Exception('DB接続に失敗しました。'));
}
}
/**
* 外部システムから企業情報を取得する(非純粋関数)
*/
function getEnterpriseMasterResponseData(Collection $corporate_numbers): Result
{
// 法人番号リストが空の場合は空のコレクションをラップしたOkを返す
if ($corporate_numbers->isEmpty()) {
return new Ok(collect());
}
try {
// 外部システムから企業情報を取得
return new Ok($this->getCompanyDataFromExternalSystem($corporate_numbers));
} catch (Exception $e) {
// 外部システム接続失敗時は、エラーメッセージを格納したExceptionをラップしたErrを返す
return new Err(new Exception('企業情報取得に失敗しました。'));
}
}
/**
* フロントエンドに返却するためのデータを整形する(純粋関数)
*/
function formatResult(Result $external_system_result, Result $db_result): array
{
// DBからのデータ取得に失敗した場合は、エラーメッセージと空配列を返す
if ($db_result->isErr()) {
return [
'company_list' => [],
'error_message' => $db_result->unwrap()->getMessage(),
];
}
// 外部システムからのデータ取得に失敗した場合は、DBから取得したデータのみでリストを作成して返す
if ($external_system_result->isErr()) {
return [
'company_list' => $this->convertToViewModelsOnlyDbData($db_result->unwrap())->toArray(),
'error_message' => $external_system_result->unwrap()->getMessage(),
];
}
// 正常時は外部システムから取得した企業情報とDBから取得した企業リストをマージして返す
return [
'company_list' => $this->convertToViewModels($db_result->unwrap(), $external_system_result->unwrap())->toArray(),
'error_message' => '',
];
}
変更した点
副作用のある処理の戻り値をResult型に変更
変更前のコードでは、DB接続時は例外を投げ、外部システム接続失敗時はnullを返却していました。
そこで戻り値をResult
型に変更することで、try-catch
やnull
チェックをせずともエラーハンドリングをすることが可能になりました。
また成功時も失敗時もResult
型を返すため、戻り値の型宣言も嘘をついていません。
さらに、戻り値の型をResult
で宣言することで、失敗する可能性のある処理をする関数であることが一目で分かるようになりました。
これは関数のシグネチャがその関数のふるまいをより正確に表現していることを意味します。
PHPStanような静的解析ツールと組み合わせることで、型を厳格に扱うことができ、関数のシグネチャがより正確になります。
フロントに返却するデータの整形を純粋関数に変更
フロントに返却するデータを整形する処理をDB接続、外部システム接続という副作用のある処理と分離し、Result
型を受け取ることで、純粋関数にすることができました!
さらにメソッド内では、Result
型のisOk()
とisErr()
を使うことで、エラー時と正常時の処理が一目でわかるようになっています。
まとめ
今回の記事を通じて、PHPでも関数型プログラミングのアプローチを取り入れられることが分かりました!
特にResult
型を導入することで、副作用の伴う処理の戻り値を安全にかつ明示的に扱えるようになり、コードの安全性と予測可能性を高めることができました。
ただ、Result
型を実際に取り入れるかどうかは慎重な検討が必要だと感じました。
Result
型はPHPに標準で実装されている概念ではないため、チームメンバーが意図や目的を十分に理解していない場合、かえって可読性が低下する可能性があります。
まずはチーム内で関数型のメリットや考え方への理解を深めることから始めるのが良いかもしれないですね。
今回ご紹介した書籍には、関数型プログラミングのメリットや実践方法が詳細に書かれています。
ぜひお手に取って読んでみてください!
補足
エラーと例外をどう扱い分けるか?についても議論が必要になりそうですが、本記事の本筋から逸れるためここでは触れません。
Discussion
「なっとく!関数型プログラミング」という書籍については、巷でかなり過剰評価されている本で、実際内容は間違いだらけです。
その書籍をもとに、今回くみねこさんがこのように理解して「解説」されていることからも、その品質の程度は追認できると思いました。
まず、シグネチャが嘘をつかない、というのは型の話であり、これは命令型だろうが関数型であろうが当たり前の話で、関数型プログラミングとは本質的に関係ありません。
たとえば、TypeScriptでコードを書いているときに、コンパイラはそのコードが命令型/関数型であることは関知していませんが、それによってTypeScriptの型システムがシグネチャの判定を変えることはありえないですよね。
「純粋関数」というのは、単に普通の数学の関数のことです。 ほんとうにただそれだけのことです。
ただそれだけのことを、このように解説している、ということは、ほんとうにただそれだけのことをわけをわからなくしている、ということなのでかなり深刻な事態なんですね。
なんでわざわざ「純粋」というのかというと、これは単に命令型プログラミングのアンチテーゼでそう呼ばれているだけです。
命令型プログラミングの関数が数学の関数とは異なる、つまり我々は、中学の数学で習った「関数」と(命令型)プログラミングの「関数」とは違う、とプログラミング学習の最初の最初にまず洗脳されるわけですが、それを アンラーニング するために「純粋関数」と特別な名前をつけているだけなんですね。
実際に、関数を教える中学数学の授業で
純粋関数とは、以下を満たす関数とのことです(書籍から引用)
みたいな歪な知識を生徒に学習させる数学の授業は世界中にどこにも存在してないわけで、いかに異常な解説がまかりとおっているか、この巷で過剰評価されている本が間違った知識を再生産しているか、という証左でしかないと思っています。
これもそうですね。「参照透過性」という言葉は関数型界隈で捏造されたに等しい用語です。
「純粋関数」=ただの中学数学で義務教育で全員習う「数学の関数」のこと、で、数学あるいは算数の「計算」とは、「常に同じ結果」を返すのはアタリマエなので、これは何も説明していないに等しい。
当然「純粋関数」なんて言葉も「参照透過性」という言葉も数学用語としては存在していません。自明だからだし、小中高でこんな奇妙な概念を習った人は誰もいないでしょう。当たり前のことなので。
できたら、そういう歪で間違った知識を再生産する過剰評価されている本を広めるくらいなら、自分が無料で公開しているチャプターを引用してくれたらいいのに、、、
といつも思っていますが、誰もしてくれないんですよね。巷にアンチが多い影響からでしょうか?そう思って、独立した記事にもしています。
最後に、関数型プログラミングとはなんぞや
についてですが、最も根元的なスペックは、関数がファーストクラスの値、つまり、関数自体が、別の関数の引数や返り値になれることです。
これが最大かつ最低限のプログラミング言語が関数型か否かを判定するスペックであり、関数が別に副作用があっても関数型プログラミングは成立します。
たとえば、なんらかの単純な「純粋関数」がコードにあったとして、その挙動を確認したいから、そこに
console.log(関数の引数); //debug log
みたいに副作用を挿入して「非純粋関数」にしようが、引数と戻り値は変わらないんだから、関数型プログラミングが破綻した!!とワーワー言う人なんていないでしょう。言語でそういうのを一切許容しないのはHaskellみたいな純粋関数型言語だけで、F#やら巷のML系でもScalaでもそんなことでワーワー言わないのです。ちなみに上で引用した自分の本は最近、全面改訂して新刊リリースしたので、間違った知識をこのように再生産しつづける過剰評価されている本を広めるくらいならば自分の本を読んでいただいたほうが随分マシだろうなとも思います。
【書評】シグネチャがウソをつかない??「なっとく!関数型プログラミング」が巷でやたら過剰評価されている件について