PHPの高階関数とモナド

PHPプログラムの中で、主なものは手続き型であり、最近のバージョンでは部分的にオブジェクト指向のプログラミングスタイルです。 しかし、別の方法で書くこともできます。これに関連して、機能スタイルについてお話したいと思います。このためのいくつかのツールはPHPでも使用できるためです。


そのため、JSONパーサーの実装を単純な関数の形式で検討し、それらをより複雑な関数に組み合わせて、徐々に本格的なJSONパーサー形式に到達していきます。 取得するコードの例を次に示します。


$jNumber = _do(function() { $number = yield literal('-')->orElse( literal('+') )->orElse( just('') ); $number .= yield takeOf('[0-9]')->onlyIf( notEmpty() ); if ( yield literal('.')->orElse( just(false) ) ) { $number .= '.'. yield takeOf('[0-9]'); } return +$number; }); 

機能的なアプローチ自体に加えて、DSLのような構文を作成するためのクラスの使用と、コンビネーターの構文を簡素化するためのジェネレーターの使用に注意を払うことができます。


JSONを単独で解析するUPDATEは、長い間解決されたタスクであり、もちろん、Cで既製でテストされた関数がよりよく機能します。 この記事では、この問題を例として使用して、機能的なアプローチを説明します。 また、そのようなコードを本番環境で使用することは推奨されていません。誰でもコードと生活を簡素化できるアイデアを得ることができます。


完全なコードはgithubにあります。


機能的なスタイル


プログラマは、プログラムの膨大な複雑さにどのように対処しますか? 彼は単純なブロックを取得し、それらからより複雑なブロックを作成し、さらに複雑なブロックを作成し、最終的にプログラムを作成します。 少なくとも、サブプログラムを備えた最初の言語が登場した後です。


手続き型スタイルの基礎は、いくつかの共通データを変更する他のプロシージャを一緒に呼び出すプロシージャの説明です。 オブジェクト指向スタイルは、他のデータ構造で構成されるデータ構造を記述する機能を追加します。 機能的スタイルは、機能の構成(接続)を使用します。


関数の構成とプロシージャとオブジェクトの構成の違いは何ですか? 機能的アプローチの基礎は機能の純度です。つまり、機能の作業の結果は入力パラメーターのみに依存します。 関数が純粋な場合、合成の結果を予測し、他の関数を変換するための既製の関数を作成することもはるかに簡単です。


結果として他の関数を受け入れたり返したりする関数は、高階関数と呼ばれ、この記事のトピックを表します。


どのような問題を解決しますか?


たとえば、全体を非常に簡単に解決できないタスクを取り上げ、機能的なアプローチがこのタスクを簡素化するのにどのように役立つかを確認してください。


たとえば、JSON形式のパーサーを作成してみましょう。これは、JSON文字列から対応するPHPオブジェクトを取得します:数字、文字列、リスト、または関連リスト(もちろん、すべてのネストされた値を含む)。


パーサーとは何ですか?


最も単純な要素から始めましょう:パーサーを書いていますが、それは何ですか? パーサーは、文字列を受け取り、成功した場合、値のペアを返します:解析結果と文字列の残りの部分(解析された値が文字列全体を占めていない場合)または文字列を解析できなかった場合は空のセット:


 Parser: string => [x,string] | [] 

たとえば、 numberパーサー関数がある場合、次のようなテストを作成できます。


 assert(number('123;') === [123,';']); assert(number('none') === []); 

免責事項 :PHPには、関数を操作するためのあまり便利な構文がないため、よりシンプルでわかりやすいコードのために、パーサー関数のラッパーにすぎないクラスを使用し、型を指定してチェーン呼び出しに便利な構文を使用する必要があります。これについては後で詳しく説明します。


 class Parser { const FAILED = []; private $parse; function __construct(callable $parse) { $this->parse = $parse; } function __invoke(string $s): array { return ($this->parse)($s); } } 

しかし、 Parserstring => array関数に過ぎないことを思い出してください。


便宜上、 parser関数も導入します。簡潔にするために、 new Parserコンストラクターを呼び出す代わりに使用します。


 function parser($f, $scope = null) { return new Parser($f->bindTo($scope)); } 

シンプルなパーサー


そのため、パーサーとは何かを理解しましたが、単一のパーサーは作成していません。修正しましょう。 次に、ソース文字列に関係なく常に1を返すパーサーの例を示します。


 $alwaysOne = parser(function($s) { return [1, $s]; }); assert($alwaysOne('123') === [1, '123']); 

この関数の有用性は明らかではありません。より一般的にして、任意の値に対して同様のパーサーを作成できる関数を宣言しましょう。


 function just($x): Parser { return parser(function($s) use ($x) { return [ $x, $s ]; }); } 

これまでのところ、すべてが単純ですが、文字列を解析し、常に同じものを返すとは限らないため、あまり有用ではありません。 入力文字列の最初の数文字を返すパーサーを作成してみましょう。


 function take(int $n): Parser { return parser(function($s) use ($n) { return strlen($s) < $n ? Parser::FAILED : [ substr($s, 0, $n), substr($s, $n) ]; }); } test(take(2), 'abc', ['ab','c']); test(take(4), 'abc', Parser::FAILED); 

本当に良いのは、文字列を実際に解析する最初のパーサーです! 思い出させてください。より複雑なパーサーを組み立てるための最も簡単なブリックについて説明します。 したがって、図を完成させるには、何もまったく解析しないパーサーのみが必要です。


 function none(): Parser { return parser(function($s) { return Parser::FAILED; }); } 

彼はまだ私たちにとって有用です。


必要なパーサーはこれだけです。 JSONを解析するにはこれで十分です。 信じられない? これらのレンガをより複雑なブロックに組み立てる方法を思い付くことが残っています。


レンガをまとめる


関数型プログラミングを行うことにしたので、関数を使用してパーサー関数をより複雑なパーサーに結合することは論理的です!


たとえば、 firstsecondパーサーがあり、それらのいずれかを文字列に適用する場合、パーサーコンビネーター(既存のパーサーに基づいて新しいパーサーを作成する関数)を定義できます。


 function oneOf(Parser $first, Parser $second): Parser { return parser(function($s) use ($first,$second) { $result = $first($s); if ($result === Parser::FAILED) { $result = $second($s); } return $result; }); } test(oneOf(none(),just(1)), '123', [1,'123']); 

しかし、上記のように、この構文はすぐに読めなくなる可能性があるため(たとえば、 oneOf($a,oneOf($b,oneOf($c,$d))) )、この(および以下のすべての)関数をクラスのメソッドとして書き換えますParser


 function orElse(Parser $alternative): Parser { return parser(function($s) use ($alternative) { $result = $this($s); if ($result === Parser::FAILED) { $result = $alternative($s); } return $result; }, $this); // <-     parser  bindTo:   $this   } test(none()->orElse(just(1)), '123', [1,'123']); 

これはすでに優れています。上記の代わりに、 $a->orElse($b)->orElse($c)->orElse($d)ことができます。


そしてもう1つ、それほど単純ではありませんが、はるかに強力な機能です。


 function flatMap(callable $f): Parser { return parser(function($s) use ($f) { $result = $this($s); if ($result != Parser::FAILED) { list ($x, $rest) = $result; $next = $f($x); $result = $next($rest); } return $result; }, $this); } 

それをより詳細に扱いましょう。 関数f: x => Parserを使用します。これは、既存のパーサーの解析結果を取得し、それに基づいて新しいパーサーを返します。これは、以前のパーサーが停止した行を解析し続けます。


例:


 test(take(1), '1234', ['1','234']); test(take(2), '234', ['23', '4']); test( take(1)->flatMap(function($x) { # x --   take(1) return take(2)->flatMap(function($y) use ($x) { # y --   take(2) return just("$x~$y"); # --   }); }), '1234', ['1~23','4'] ); 

したがって、 take(1)take(2)just("$x~$y")を組み合わせて、最初に1文字を解析し、その後に2文字を解析して$x~$y形式で返すかなり複雑なパーサーを取得しました。


行われた作業の主な特徴は、解析結果をどう処理するかを説明することですが、解析された文字列自体はここには含まれません。文字列のどの部分を転送するかを間違えることはできません。 そして、そのような組み合わせの構文をよりシンプルで読みやすくする方法を見ていきます。


この関数を使用すると、他のいくつかの便利なコンビネーターを説明できます。


 function onlyIf(callable $predicate): Parser { return $this->flatMap(function($x) use ($predicate) { return $predicate($x) ? just($x) : none(); }); } 

このコンビネータを使用すると、パーサーのアクションを指定し、その結果をいくつかの基準に準拠しているかどうかを確認できます。 たとえば、これを使用して、非常に便利なパーサーを構築します。


 function literal(string $value): Parser { return take(strlen($value))->onlyIf(function($actual) use ($value) { return $actual === $value; }); } test(literal('test'), 'test1', ['test','1']); test(literal('test'), 'some1', []); 

DO表記


最も簡単なtakejust 、およびnoneパーサー、それらを組み合わせるためのメソッド( orElseflatMaponlyIf )について説明し、リテラルパーサーについても説明しました。


ここで、より複雑なパーサーの構築を開始しますが、その前に、それらをより簡単に記述する方法を作りたいとflatMapます。結合関数flatMap使用すると、多くのことができますが、見栄えはよくありません。


この点で、他の言語がこの問題をどのように解決するかを見ていきます。 そのため、HaskellおよびScala言語では、そのようなものを操作するための非常に便利な構文があります(独自の名前-モナドもあります)、これは(Haskellでは)DO表記と呼ばれます。


flatMap基本的に何をしますか? これにより、実際に解析せずに解析結果をどう処理するかを説明できます。 つまり 中間結果が受信されるまで、手順は何らかの形で中断されます。 この効果を実装するには、PHPの新しい構文-ジェネレーターを使用できます。


発電機


少し脱線して、ジェネレーターとは何かを考えてみましょう。 PHP 5.5.0以降では、関数を記述することが可能になりました。


 function generator() { yield 1; yield 2; yield 3; } foreach (generator() as $i) print $i; # -> 123 

さらに興味深いのは、データはジェネレーターから取得できるだけでなく、 yieldを介してデータを転送でき、バージョン7からでもgetReturnてジェネレーターの結果を取得できることです。


 function suspendable() { $first = yield "first"; $second = yield "second"; return $first.$second; } $gen = suspendable(); while ($gen->valid()) { $current = $gen->current(); print $current.','; $gen->send($current.'!'); } print $gen->getReturn(); # -> first,second,first!second! 

これは、プログラマーからflatMap呼び出しを隠すために使用できます。


flatMapを使用したflatMap


 function _do(Closure $gen, $scope = null) { $step = function ($body) use (&$step) { if (! $body->valid()) { $result = $body->getReturn(); return is_null($result) ? none() : just($result); } else { return $body->current()->flatMap( function($x) use (&$step, $body) { $body->send($x); return $step($body); }); } }; $gen = $gen->bindTo($scope); return parser(function($text) use ($step,$gen) { return $step($gen())($text); }); } 

この関数は、ジェネレーター(結果を取得するパーサーを含む)の各yieldを取得し、 flatMapを介して残りのコードフラグメント(再帰関数step形式)と結合します。


再帰なしで同じものとflatMap関数は次のように書くことができます:


 function _do(Closure $gen, $scope = null) { $gen = $gen->bindTo($scope); #   $this   return parser(function($text) use ($gen) { $body = gen(); while ($body->valid()) { $next = $body->current(); $result = $next($text); if ($result === Parser::FAILED) { return Parser::FAILED; } list($x,$text) = $result; $body->send($x); } return $body->getReturn(); }); } 

しかし、最初のエントリは、パーサーに特に関連付けられておらず、関数flatMapjustおよびnoneのみを備えているという点でさらに興味深いnone (さらに、特別な方法でnullを処理し、 noneを省くために書き換えることもできます)。


2つのflatMapjustメソッドを使用して結合できるオブジェクトはモナドと呼ばれ(これはわずかに簡略化された定義です)、同じコードを使用してPromises、オプション値(多分、Option)など多くのコンビflatMapを作成できます。


しかし、これを書いたもののために、これは最も単純な関数ではありませんか? 引き続きflatMapを使いやすくするため。 同じコードをクリーンなflatMapと比較します:


 test( take(1)->flatMap(function($x) { return take(2)->flatMap(function($y) use ($x) { return just("$x~$y"); }); }), '1234', ['1~23','4'] ); 

と同じコードですが、 _do介して記述されてい_do


 test( _do(function() { $x = yield take(1); $y = yield take(2); return "$x~$y"; }), '1234', ['1~23','4'] ); 

結果のパーサーは同じ方法で同じことを行いますが、そのようなコードの読み取りと書き込みははるかに簡単です!


より複雑なパーサーとコンビネーターの構築


これで、この表記法を使用して、さらに便利なパーサーを作成できます。


 function takeWhile(callable $predicate): Parser { return _do(function() use ($predicate) { $c = yield take(1)->onlyIf($predicate)->orElse(just('')); if ($c !== '') { $rest = yield takeWhile($predicate); return $c.$rest; } else { return ''; } }); } function takeOf(string $pattern): Parser { return takeWhile(function($c) use ($pattern) { return preg_match("/^$pattern$/", $c); }); } test(takeOf('[0-9]'), '123abc', ['123','abc' ]); test(takeOf('[az]'), '123abc', [ '','123abc']); 

そして、要素を繰り返すための便利なParserクラスメソッド:


 function repeated(): Parser { $atLeastOne = _do(function() { $first = yield $this; $rest = yield $this->repeated(); return array_merge([$first],$rest); },$this); return $atLeastOne->orElse(just([])); } function separatedBy(Parser $separator): Parser { $self = $this; $atLeastOne = _do(function() use ($separator) { $first = yield $this; $rest = yield $this->prefixedWith($separator)->repeated(); return array_merge([$first], $rest); },$this); return $atLeastOne->orElse(just([])); } 

ジョンソン


個別に記述したパーサーとコンビflatMapはそれぞれ単純です(おそらくflatMap_doを除きますが、2つしかなく、非常に普遍的です)。しかし、それらを使用することでJSONパーサーを簡単に記述できるようになります。


jNumber = ('-'|'+'|'') [0-9]+ (.[0-9]+)?


 $jNumber = _do(function() { $number = yield literal('-')->orElse(literal('+'))->orElse(just('')); $number .= yield takeOf('[0-9]'); if (yield literal('.')->orElse(just(false))) { $number .= '.'. yield takeOf('[0-9]'); } if ($number !== '') return +$number; }); 

コードは非常に自己文書化されており、コードの読み取りとエラーの検索は非常に簡単です。


jBool = true | false


 $jBool = literal('true')->orElse(literal('false'))->flatMap(function($value) { return just($value === 'true'); }); 

jString = '"' [^"]* '"'


 $jString = _do(function() { yield literal('"'); $value = yield takeOf('[^"]'); yield literal('"'); return $value; }); 

jList = '[' (jValue (, jValue)*)? ']'


 $jList = _do(function() use (&$jValue) { yield literal('['); $items = yield $jValue->separatedBy(literal(',')); yield literal(']'); return $items; }); 

jObject = '{' (pair (, pair)*)? '}'


 $jObject = _do(function() use (&$jValue) { yield literal('{'); $result = []; $pair = _do(function() use (&$jValue,&$result) { $key = yield takeOf('\\w'); yield literal(':'); $value = yield $jValue; $result[$key] = $value; return true; }); yield $pair->separatedBy(literal(',')); yield literal('}'); return $result; }); 

jValue = jNull | jBool | jNumber | jString | jList | jObject


 $jValue = $jNull->orElse($jBool)->orElse($jNumber)->orElse($jString)->orElse($jList)->orElse($jObject); 

JSON jValueパーサーの準備ができました! そして、最初はそうだったように、それはあまり理解できないようには見えません。 いくつかのパフォーマンスノートがありますが、それらは行の分割方法を置き換えることで解決されます(たとえば、 string => [x, string]代わりに、 [string,index] => [x,string,index]を使用して、複数の改行を回避できます)。 そして、この種の変更については、 justtakeflatMapを書き換えるjustで十分で、それらに基づいて構築された残りのコードは変更されません。


おわりに


もちろん、私の目標は、次のJSONパーサーを書くことではなく、小さな単純な(機能的にクリーンな)関数の記述方法と、それらを組み合わせる簡単な方法を示すことで、複雑な関数を簡単な方法で構築できるようにすることです。


そして、シンプルで理解可能なコードでは、エラーが少なくなります。 機能的なアプローチを恐れないでください。



Source: https://habr.com/ru/post/J309962/


All Articles