Share
Embed
パーサーコンビネーターの実装を通じて学ぶ宣言的プログラミングの魅力 #phpcon_ehime / phpcon-ehime-2026
2026年10月3日に愛知大学城北キャンパスで開催されたPHPカンファレンス愛媛2026の登壇資料です。
shogogg
October 03, 2026
Transcript
67 slides
-
自己紹介 河瀨 翔吾 / Shogo Kawase エンジニアリングマネージャー I LOVE... 妻
/ 設計 / 型安全 / アジャイル /ももクロ 麻雀 / F1 / マリオカート / ACE COMBAT shogogg shogogg -
HTML標準におけるメールアドレスの定義 email = 1*( atext / "." ) "@" label
*( "." label ) label = let-dig [ [ ldh-str ] let-dig ] atext = < as defined in RFC 5322 section 3.2.3 > ; limited to a length of 63 characters by RFC 1034 section 3.5 let-dig = < as defined in RFC 1034 section 3.5 > ldh-str = < as defined in RFC 1034 section 3.5 > HTML Standard 4.10.5.1.5 Email state より引用 -
パーサーコンビネーターなら…… // <let-dig> ::= <letter> | <digit> ; from RFC
1034 $letDig = Pico::anyOf(Pico::alpha(), Pico::digit()); // <let-dig-hyp> ::= <let-dig> | "-" ; from RFC 1034 $letDigHyp = Pico::anyOf($letDig, Pico::char('-')); // <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str> ; from RFC 1034 $ldhStr = $letDigHyp->repeat(min: 1)->concat(); -
パーサーコンビネーターなら…… // label = let-dig [ [ldh-str] let-dig ] $label
= $ldhStr->where(static function (string $value): bool { // Must be limited to 63 characters, where both the first and last characters are let-dig. return strlen($value) <= 63 && ctype_alnum($value[0]) && ctype_alnum($value[-1]); }); // atext = ALPHA / DIGIT / "!" / "#" / "$" / ... ; from RFC 5322 $atext = Pico::anyOf(Pico::alpha(), Pico::digit(), Pico::oneOf("!#$%&'*+-/=?^_`{|}~")); // email = 1*( atext / "." ) "@" label *( "." label ) $email = Pico::concat( Pico::anyOf($atext, Pico::char('.'))->repeat(min: 1)->concat(), Pico::char('@'), Pico::sepBy($label, Pico::char('.'), min: 1)->join('.'), ); $email->parse('[email protected]')->isSuccess(); // true -
パーサーコンビネーターなら…… // label = let-dig [ [ldh-str] let-dig ] $label
= $ldhStr->where(static function (string $value): bool { // Must be limited to 63 characters, where both the first and last characters are let-dig. return strlen($value) <= 63 && ctype_alnum($value[0]) && ctype_alnum($value[-1]); }); 読める……読めるぞ! // atext = ALPHA / DIGIT / "!" / "#" / "$" / ... ; from RFC 5322 $atext = Pico::anyOf(Pico::alpha(), Pico::digit(), Pico::oneOf("!#$%&'*+-/=?^_`{|}~")); // email = 1*( atext / "." ) "@" label *( "." label ) $email = Pico::concat( Pico::anyOf($atext, Pico::char('.'))->repeat(min: 1)->concat(), Pico::char('@'), Pico::sepBy($label, Pico::char('.'), min: 1)->join('.'), ); $email->parse('[email protected]')->isSuccess(); // true -
最小限の小さなパーサー達(1) // anyChar: 任意の文字 Pico::anyChar()->parse('🍣=寿司'); // Success('🍣', 1) // ascii:
任意の ASCII 文字 Pico::ascii()->parse('ASCII'); // Success('A', 1) // alpha: 任意のアルファベット(半角英字) Pico::alpha()->parse('alphabet'); // Success('a', 1) // digit: 任意の半角数字 Pico::digit()->parse('3.141592'); // Success('3', 1) // alphaNum: 任意の半角英数字 Pico::alphaNum()->parse('22!'); // Success('2', 1) // whitespace: 任意の空白文字 Pico::whitespace()->parse("\tall"); // Success("\t", 1) -
最小限の小さなパーサー達(2) // char: 指定された文字 Pico::char('A')->parse('ACE COMBAT 8'); // Success('A', 1)
// oneOf: 指定されたいずれかの文字 Pico::oneOf('ABF')->parse('Fighter'); // Success('F', 1) // range: 指定された範囲の文字 Pico::range('あ', 'お')->parse('うえ'); // Success('う', 1) // string: 連続して一致する文字列 Pico::string('F-22')->parse('F-22 Raptor'); // Success('F-22', 4) // charWhere: 条件を満たす文字 Pico::charWhere(ctype_alnum(...))->parse('Mebius 1'); // Success('M', 1) // eof: 文字列の終端 Pico::eof()->parse(''); // Success('', 0) -
パーサーの実装(1) // anyChar: 任意の文字 return self::charWhere(static fn (): bool =>
true); // ascii: 任意の ASCII 文字 return self::charWhere(static fn (string $x): bool => strlen($x) === 1); // alpha: 任意のアルファベット(半角英字) return self::charWhere(ctype_alpha(...)); // digit: 任意の半角数字 return self::charWhere(ctype_digit(...)); // alphaNum: 任意の半角英数字 return self::charWhere(ctype_alnum(...)); // whitespace: 任意の空白文字 return self::charWhere(ctype_space(...)); パーサー=入力を受け取って結果を返す関数 ※実際のコードから少しだけ簡略化しています -
パーサーの実装(2) // char: 指定された文字 return self::charWhere(static fn (string $x): bool
=> $x === $char); // oneOf: 指定されたいずれかの文字 $characterSet = self::characterSet($characters); return self::charWhere(static fn (string $x): bool => isset($characterSet[$x])); // range: 指定された範囲の文字 return self::charWhere(static function (string $x) use ($min, $max): bool { $codePoint = mb_ord($x, 'UTF-8'); return $codePoint >= $min && $codePoint <= $max; }); // charWhere: 条件を満たす文字 return self::create(static function (ParserInput $input) use ($predicate): ParserResult { $char = $input->current(); return $predicate($char) ? success($char, 1) : failure(); }); ※実際のコードから少しだけ簡略化しています -
パーサーから新しいパーサーを作る // complete: 入力全体にマッチした場合だけ成功するパーサー Pico::digit()->complete()->parse('86'); // Failure // concat: 結果を再帰的に文字列として結合して返すパーサー
Pico::alpha()->then(Pico::alpha())->then(Pico::alpha())->concat()->parse('XYZ'); // Success('XYZ', 3) // then: 続けてパースし、両方の結果を配列で返すパーサー Pico::alpha()->then(Pico::digit())->parse('A1'); // Success(['A', '1'], 2) // repeat: 繰り返しマッチした結果を配列にして返すパーサー Pico::digit()->repeat(min: 2, max: 4)->parse('12345'); // Success(['1', '2', '3', '4'], 4) // optional: マッチしなくても空文字で成功するパーサー Pico::char('-')->optional()->then(Pico::digit())->join()->parse('5'); // Success('5', 1) // map: マッチした結果を変換して返すパーサー Pico::digit()->map(static fn (string $value): int => intval($value) * 2)->parse('4'); // Success(8, 1) // where: マッチした結果が条件を満たす場合だけ成功するパーサー Pico::digit()->where(static fn (string $value): bool => $value !== '0')->parse('0'); // Failure -
パーサーを組み合わせて新しいパーサーを作る // anyOf: 最初にマッチしたパーサーの結果を返すパーサー Pico::anyOf(Pico::char('A'), Pico::char('B'), Pico::char('F'))->parse('B-2'); // Success('B', 1)
// seq: 連続するすべてのパーサーにマッチした場合に結果の配列を返すパーサー Pico::seq(Pico::char('F'), Pico::char('-'), Pico::digit())->parse('F-4'); // Success(['F', '-', '4'], 3) // concat: 連続するすべてのパーサーにマッチした場合に結果を結合して返すパーサー Pico::concat(Pico::char('F'), Pico::char('-'), Pico::digit())->parse('F-4'); // Success('F-4', 3) // sepBy: 指定した条件で区切られた値を配列にして返すパーサー Pico::sepBy(Pico::digit(), Pico::char(','))->parse('1,2,3'); // Success([1, 2, 3], 5) // between: 指定した条件で囲まれた値を返すパーサー Pico::between(Pico::char('['), Pico::alpha(), Pico::char(']'))->parse('[A]'); // Success('A', 3) // skip: 連続するすべてのパーサーにマッチした場合に空の結果を返すパーサー Pico::skip(Pico::string('//'), Pico::whitespaces())->parse('// comment'); // Success('', 3) -
RFC-5322 によるメールアドレスの定義 atext = ALPHA / DIGIT / "!" /
"#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "/" / "=" / "?" / "^" / "_" / "`" / "{" / "|" / "}" / "~" dot-atom-text = 1*atext *("." 1*atext) dot-atom = [CFWS] dot-atom-text [CFWS] qtext = %d33 / %d35-91 / %d93-126 / obs-qtext qcontent = qtext / quoted-pair quoted-string = [CFWS] DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] addr-spec = local-part "@" domain local-part = dot-atom / quoted-string / obs-local-part domain = dot-atom / domain-literal / obs-domain domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS] dtext = %d33-90 / %d94-126 / obs-dtext -
RFC-5322 準拠メールアドレスの正規表現 /^(?=.{1,998}$)(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))* (?<local>"(?:[^"\\]|\\[\s\S]){0,62}"(?:\."(?:[^"\\]|\\[\s\S]){0,62}")*|"(?:[^"\\]|\\[\s\S]){0,62}"( ?:\.[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64}(?:\.[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64})*)+|[a-zA-Z0-9!#$ %&'*/=?^_`{|}~+-]{1,64}(?:\.[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64})*(?:\."(?:[^"\\]|\\[\s\S]){0,62}") +|[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64}(?:(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)( ?:[^()\\]|\\[\s\S])*)*\))*\.(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[ \s\S])*)*\))*[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64})*)(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\ [\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*@(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[
^()\\]|\\[\s\S])*)*\))*(?<domain>[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:(?:\s|\((?:[^()\\] |\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*\.(?:\s|\((?:[^()\\]|\\[\s\S])*(? :\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?) +|\[(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|IPv6:[0-9a-fA-F:]+)\])(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^( )\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*$/ Riya Amemiya「RFC 5321/5322に沿ったEmail正規表現を書く」より引用 -
RFC-5322 準拠メールアドレスの正規表現 /^(?=.{1,998}$)(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))* (?<local>"(?:[^"\\]|\\[\s\S]){0,62}"(?:\."(?:[^"\\]|\\[\s\S]){0,62}")*|"(?:[^"\\]|\\[\s\S]){0,62}"( ?:\.[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64}(?:\.[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64})*)+|[a-zA-Z0-9!#$ %&'*/=?^_`{|}~+-]{1,64}(?:\.[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64})*(?:\."(?:[^"\\]|\\[\s\S]){0,62}") +|[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64}(?:(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)( ?:[^()\\]|\\[\s\S])*)*\))*\.(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[ \s\S])*)*\))*[a-zA-Z0-9!#$%&'*/=?^_`{|}~+-]{1,64})*)(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\ [\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*@(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[
^()\\]|\\[\s\S])*)*\))*(?<domain>[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:(?:\s|\((?:[^()\\] |\\[\s\S])*(?:\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*\.(?:\s|\((?:[^()\\]|\\[\s\S])*(? :\((?:[^()\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?) +|\[(?:(?:[0-9]{1,3}\.){3}[0-9]{1,3}|IPv6:[0-9a-fA-F:]+)\])(?:\s|\((?:[^()\\]|\\[\s\S])*(?:\((?:[^( )\\]|\\[\s\S])*\)(?:[^()\\]|\\[\s\S])*)*\))*$/ Riya Amemiya「RFC 5321/5322に沿ったEmail正規表現を書く」より引用 -
RFC-5322 パーサーの実装(抜粋) // atext = ALPHA / DIGIT / "!"
/ "#" / ... $atext = Pico::anyOf( Pico::alpha(), Pico::digit(), Pico::oneOf("!#$%&'*+-/=?^_`{|}~"), ); // dot-atom-text = 1*atext *("." 1*atext) $dotAtomText = Pico::concat( $atext->repeat(min: 1), Pico::seq(Pico::char('.'), $atext->repeat(min: 1))->repeat(), ); // dot-atom = [CFWS] dot-atom-text [CFWS] $dotAtom = Pico::between( $cfws->optional(), $dotAtomText, $cfws->optional(), ); ※実際のコードから少しだけ簡略化しています -
RFC-5322 パーサーの実装(抜粋) // qtext = %d33 / %d35-91 / %d93-126
$qtext = Pico::anyOf( Pico::char(chr(33)), Pico::range(chr(35), chr(91)), Pico::range(chr(93), chr(126)), ); // qcontent = qtext / quoted-pair $qcontent = Pico::anyOf($qtext, $quotedPair); // quoted-string = [CFWS] DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] $quotedString = Pico::concat( $cfws->optional()->skip(), $dquote->skip(), Pico::seq($fws->optional(), $qcontent)->repeat(), $fws->optional(), $dquote->skip(), $cfws->optional()->skip(), ); ※実際のコードから少しだけ簡略化しています -
RFC-5322 パーサーの実装(抜粋) // dtext = %d33-90 / %d94-126 ; Printable
US-ASCII characters not including "[", "]", or "\" $dtext = Pico::anyOf( Pico::range(chr(33), chr(90)), Pico::range(chr(94), chr(126)), ); // domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS] $domainLiteral = Pico::concat( $cfws->optional()->skip(), Pico::char('['), Pico::seq($fws->optional(), $dtext)->repeat(), $fws->optional(), Pico::char(']'), $cfws->optional()->skip(), ); ※実際のコードから少しだけ簡略化しています -
RFC-5322 パーサーの実装(抜粋) // local-part = dot-atom / quoted-string $localPart =
Pico::anyOf($dotAtom, $quotedString); // domain = dot-atom / domain-literal $domain = Pico::anyOf($dotAtom, $domainLiteral); // addr-spec = local-part "@" domain $addrSpec = Pico::concat( $localPart, Pico::char('@'), $domain, ); ※実際のコードから少しだけ簡略化しています -
JSON String - Simple Version private static function string(): Parser
{ $char = Pico::anyOf( Pico::string('\\"')->map(static fn (): string => '"'), Pico::anyChar()->except(Pico::char('"')), ); return Pico::between( Pico::char('"'), $char->repeat()->join(), Pico::char('"'), ); } ※実際のコードから少しだけ簡略化しています -
JSON String - Full Version private static function string(): Parser
{ // unescaped = %x20-21 / %x23-5B / %x5D-10FFFF // = %x20-10FFFF except %x22 or %x5C $unescaped = Pico::range("\x20", "\u{10FFFF}") ->except(Pico::char("\x22")) ->except(Pico::char("\x5C")) ->repeat(min: 1) ->join(); $simpleEscape = Pico::skipLeft(Pico::char('\\'), Pico::oneOf('"\\\\/bfnrt'))->map(fn (string $x): string => match ($x) { '"', '\\', '/' => $x, 'b' => "\x08", 'f' => "\x0C", 'n' => "\n", 'r' => "\r", 't' => "\t", default => throw new ParserException('The JSON string contains an invalid escape sequence.'), }); $unicodeEscape = Pico::skipLeft( Pico::string('\\u'), Pico::regexp('[0-9A-Fa-f]{4}'), )->map(static function (string $codeUnit): int { return intval($codeUnit, 16); }); $highSurrogate = $unicodeEscape->where(static function (int $codeUnit): bool { return $codeUnit >= 0xD800 && $codeUnit <= 0xDBFF; }); $lowSurrogate = $unicodeEscape->where(static function (int $codeUnit): bool { return $codeUnit >= 0xDC00 && $codeUnit <= 0xDFFF; }); $notSurrogate = $unicodeEscape->where(static function (int $codeUnit): bool { return $codeUnit < 0xD800 || $codeUnit > 0xDFFF; }); $surrogatePair = Pico::seq($highSurrogate, $lowSurrogate)->map(static function (array $codeUnits): string { [$a, $b] = $codeUnits; return mb_chr(0x10000 + (($a - 0xD800) << 10) + ($b - 0xDC00), 'UTF-8'); }); $codeUnit = $notSurrogate->map(static fn (int $codeUnit): string => mb_chr($codeUnit, 'UTF-8')); $escaped = Pico::anyOf($simpleEscape, $surrogatePair, $codeUnit); return Pico::between( Pico::char('"'), Pico::anyOf($unescaped, $escaped)->repeat()->join(), Pico::char('"'), ); } ※実際のコードから少しだけ簡略化しています -
JSON Number - Simple Version private static function number(): Parser
{ $minus = Pico::char('-')->optional(); $integerPart = Pico::anyOf( Pico::char('0'), Pico::join(Pico::range('1', '9'), Pico::digit()->repeat()->join()), ); return Pico::join($minus, $integerPart)->map(intval(...)); } ※実際のコードから少しだけ簡略化しています -
JSON Array private static function array(): Parser { $emptyArray =
Pico::seq( Pico::char('['), self::whitespace(), Pico::char(']'), ); $nonEmptyArray = Pico::between( Pico::char('['), Pico::sepBy(self::value(), Pico::char(',')), Pico::char(']'), ); return Pico::anyOf( $emptyArray->map(static fn (): array => []), $nonEmptyArray, ); } ※実際のコードから少しだけ簡略化しています -
JSON Object private static function object(): Parser { $whitespace =
self::whitespace(); $objectMember = Pico::pair( Pico::between($whitespace, self::string(), $whitespace), self::value(), sep: Pico::char(':'), )->map( static fn (array $outputs): array => [$outputs[0] => $outputs[1]], ); $emptyObject = Pico::seq(Pico::char('{'), $whitespace, Pico::char('}')); $nonEmptyObject = Pico::between( Pico::char('{'), Pico::sepBy($objectMember, Pico::char(','))->map( static fn (array $members): array => array_merge([], ...$members), ), Pico::char('}'), ); return Pico::anyOf( $emptyObject->map(static fn (): array => []), $nonEmptyObject, ); } ※実際のコードから少しだけ簡略化しています -
JSON Value public static function value(): Parser { return Pico::between(
self::whitespace(), Pico::anyOf( self::string(), self::number(), Pico::lazy(static fn (): Parser => self::array()), Pico::lazy(static fn (): Parser => self::object()), Pico::string('true')->map(static fn (): true => true), Pico::string('false')->map(static fn (): false => false), Pico::string('null')->map(static fn (): null => null), ), self::whitespace(), ); } ※実際のコードから少しだけ簡略化しています -
パーサーコンビネーターでできること データ表現の解析 • JSON や YAML などの階層構造・エスケープ処理のデコード • 独自データフォーマット(例:全銀ファイル)の安全な読み込み ドメイン固有言語(DSL)の解
析 • status:active AND (-is:deleted OR tag:php) のような検索クエリの解析 抽象構文木(AST)への変換 • 数式(四則演算)や SQL から抽象構文木(AST)への変換 -
その他のレシピ 今回紹介したレシピ以外にも参考コードを GitHub で公開しています • メールアドレス(HTML Standard 版) • メールアドレス(RFC-5322
版) • JSON(簡易版) • JSON(完全版) • 数式(四則演算・簡易版) https://x.gd/pico_recipe -
プログラミング言語の分類(パラダイム) 命令型 • 手続き型言語(C / COBOL / Go など) •
オブジェクト指向言語(C++ / Java / JavaScript など) 宣言型 • 関数型言語(Haskell / LISP / OCaml / Elixir など) • 論理型言語(Prolog など) マルチパラダイム • 複数のパラダイムを併せ持つ言語(Scala / Kotlin / Rust / PHP など) -
命令的 vs 宣言的 命令的 比較項目 宣言的 どのようにするか(How) 記述 何が必要か(What) 成果を得るための手順
コードの役割 成果が満たすべき条件 if やループによる制御が中心 コードの形 定義の積み重ねが中心 コードで明示的に制御 状態の扱い 処理系に委任し隠蔽 -
“the single biggest remaining cause of complexity in most contemporary
large systems is state” “現代の大規模システムにおける複雑さの、残された最大の要因は「状態」である” Source: Ben Moseley & Peter Marks, “Out of the Tar Pit” (2006) -
ショッピングカートの送料計算(命令的) $subtotal = 0; // 在庫がある商品のみ注文金額に加算していく foreach ($items as $item)
{ if ($item->inStock) { $subtotal += $item->subtotal(); } } // 注文が5,000円以上の場合は送料無料 $shippingFee = 500; if ($subtotal >= 5000) { $shippingFee = 0; } // 合計=注文金額+送料 return [ 'subtotal' => $subtotal, 'shippingFee' => $shippingFee, 'total' => $subtotal + $shippingFee, ]; -
ショッピングカートの送料計算(宣言的) // 注文金額=在庫がある商品の小計の合計 $subtotal = $items |> array_filter(?, static fn
(Item $x): bool => $x->inStock) |> array_map(static fn (Item $x): int => $x->subtotal(), ?) |> array_sum(...); // 送料=注文が5,000円未満の場合は500円、5,000以上の場合は無料 $shippingFee = $subtotal < 5000 ? 500 : 0; // 合計=注文金額+送料 return [ 'subtotal' => $subtotal, 'shippingFee' => $shippingFee, 'total' => $subtotal + $shippingFee, ]; -
JSON String - Simple Version (再掲) private static function string():
Parser { $char = Pico::anyOf( Pico::string('\\"')->map(static fn (): string => '"'), Pico::anyChar()->except(Pico::char('"')), ); return Pico::between( Pico::char('"'), $char->repeat()->join(), Pico::char('"'), ); } ifもループも状態もなく、定義だけがある ※実際のコードから少しだけ簡略化しています -
JSON Number - Simple Version (再掲) private static function number():
Parser { $minus = Pico::char('-')->optional(); $integerPart = Pico::anyOf( Pico::char('0'), Pico::join(Pico::range('1', '9'), Pico::digit()->repeat()->join()), ); return Pico::join($minus, $integerPart)->map(intval(...)); } ※実際のコードから少しだけ簡略化しています -
JSON Array (再掲) private static function array(): Parser { $emptyArray
= Pico::seq( Pico::char('['), self::whitespace(), Pico::char(']'), ); $nonEmptyArray = Pico::between( Pico::char('['), Pico::sepBy(self::value(), Pico::char(',')), Pico::char(']'), ); return Pico::anyOf( $emptyArray->map(static fn (): array => []), $nonEmptyArray, ); } ※実際のコードから少しだけ簡略化しています -
JSON Object (再掲) private static function object(): Parser { $whitespace
= self::whitespace(); $objectMember = Pico::pair( Pico::between($whitespace, self::string(), $whitespace), self::value(), sep: Pico::char(':'), )->map( static fn (array $outputs): array => [$outputs[0] => $outputs[1]], ); $emptyObject = Pico::seq(Pico::char('{'), $whitespace, Pico::char('}')); $nonEmptyObject = Pico::between( Pico::char('{'), Pico::sepBy($objectMember, Pico::char(','))->map( static fn (array $members): array => array_merge([], ...$members), ), Pico::char('}'), ); return Pico::anyOf( $emptyObject->map(static fn (): array => []), $nonEmptyObject, ); } ※実際のコードから少しだけ簡略化しています -
JSON Value (再掲) public static function value(): Parser { return
Pico::between( self::whitespace(), Pico::anyOf( self::string(), self::number(), Pico::lazy(static fn (): Parser => self::array()), Pico::lazy(static fn (): Parser => self::object()), Pico::string('true')->map(static fn (): true => true), Pico::string('false')->map(static fn (): false => false), Pico::string('null')->map(static fn (): null => null), ), self::whitespace(), ); } ※実際のコードから少しだけ簡略化しています -
AIと宣言的プログラミング • 仕様とコードが 1:1 で対応する宣言的プログラミングは、AI を使ってコード を書く上でも、AI にコードを読ませる上でもメリットは大きい • 状態を扱わないことで、1つ1つのコードの意図や結果がわかりやすくなるこ
とも、AI による精度の高いコード生成や、質の高いコードレビューに繋がる • 一方で、AI が学習済みのPHPコードはそのほとんどが命令的なコード。学習 量に伴う品質は、命令的なコードの方が高いのでは?という声も • 何を優先し、何を選ぶのかは、あなた次第 -
まとめ • 宣言的プログラミング とは、How=状態や手続きを抽象化・隠蔽し、 What=定義の宣言を中心とする設計思想やプログラミング手法 • 状態や手続き、制御ロジックが隠蔽されることでコードの意図が明確となるた め、人間はもちろん、AI にとっても可読性の高いコードが書きやすい •
マルチパラダイム言語であるPHPでも宣言的プログラミングは可能 • パーサーコンビネーター は宣言的プログラミングの代表的な手法のひとつ • パーサーコンビネーターを使えば、複雑なデータ構造や文字列のパースや解析 を、高い可読性を保ちながら実現できる