Upgrade to Pro — share decks privately, control downloads, hide ads and more …

パーサーコンビネーターの実装を通じて学ぶ宣言的プログラミングの魅力 #phpco...

Avatar for shogogg shogogg
October 03, 2026

パーサーコンビネーターの実装を通じて学ぶ宣言的プログラミングの魅力 #phpcon_ehime / phpcon-ehime-2026

2026年10月3日に愛知大学城北キャンパスで開催されたPHPカンファレンス愛媛2026の登壇資料です。

Avatar for shogogg

shogogg

October 03, 2026

More Decks by shogogg

Other Decks in Programming

Transcript

  1. 自己紹介 河瀨 翔吾 / Shogo Kawase エンジニアリングマネージャー I LOVE... 妻

    / 設計 / 型安全 / アジャイル /ももクロ 麻雀 / F1 / マリオカート / ACE COMBAT shogogg shogogg
  2. 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 より引用
  3. パーサーコンビネーターなら…… // <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();
  4. パーサーコンビネーターなら…… // 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
  5. パーサーコンビネーターなら…… // 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
  6. 最小限の小さなパーサー達(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)
  7. 最小限の小さなパーサー達(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)
  8. パーサーの実装(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(...)); パーサー=入力を受け取って結果を返す関数 ※実際のコードから少しだけ簡略化しています
  9. パーサーの実装(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(); }); ※実際のコードから少しだけ簡略化しています
  10. パーサーから新しいパーサーを作る // 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
  11. パーサーを組み合わせて新しいパーサーを作る // 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)
  12. 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
  13. 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(), ); ※実際のコードから少しだけ簡略化しています
  14. 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(), ); ※実際のコードから少しだけ簡略化しています
  15. 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(), ); ※実際のコードから少しだけ簡略化しています
  16. 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, ); ※実際のコードから少しだけ簡略化しています
  17. 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('"'), ); } ※実際のコードから少しだけ簡略化しています
  18. 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('"'), ); } ※実際のコードから少しだけ簡略化しています
  19. 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(...)); } ※実際のコードから少しだけ簡略化しています
  20. 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, ); } ※実際のコードから少しだけ簡略化しています
  21. 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, ); } ※実際のコードから少しだけ簡略化しています
  22. 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(), ); } ※実際のコードから少しだけ簡略化しています
  23. パーサーコンビネーターでできること データ表現の解析 • JSON や YAML などの階層構造・エスケープ処理のデコード • 独自データフォーマット(例:全銀ファイル)の安全な読み込み ドメイン固有言語(DSL)の解

    析 • status:active AND (-is:deleted OR tag:php) のような検索クエリの解析 抽象構文木(AST)への変換 • 数式(四則演算)や SQL から抽象構文木(AST)への変換
  24. プログラミング言語の分類(パラダイム) 命令型 • 手続き型言語(C / COBOL / Go など) •

    オブジェクト指向言語(C++ / Java / JavaScript など) 宣言型 • 関数型言語(Haskell / LISP / OCaml / Elixir など) • 論理型言語(Prolog など) マルチパラダイム • 複数のパラダイムを併せ持つ言語(Scala / Kotlin / Rust / PHP など)
  25. 命令的 vs 宣言的 命令的 比較項目 宣言的 どのようにするか(How) 記述 何が必要か(What) 成果を得るための手順

    コードの役割 成果が満たすべき条件 if やループによる制御が中心 コードの形 定義の積み重ねが中心 コードで明示的に制御 状態の扱い 処理系に委任し隠蔽
  26. “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)
  27. ショッピングカートの送料計算(命令的) $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, ];
  28. ショッピングカートの送料計算(宣言的) // 注文金額=在庫がある商品の小計の合計 $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, ];
  29. 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もループも状態もなく、定義だけがある ※実際のコードから少しだけ簡略化しています
  30. 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(...)); } ※実際のコードから少しだけ簡略化しています
  31. 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, ); } ※実際のコードから少しだけ簡略化しています
  32. 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, ); } ※実際のコードから少しだけ簡略化しています
  33. 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(), ); } ※実際のコードから少しだけ簡略化しています
  34. AIと宣言的プログラミング • 仕様とコードが 1:1 で対応する宣言的プログラミングは、AI を使ってコード を書く上でも、AI にコードを読ませる上でもメリットは大きい • 状態を扱わないことで、1つ1つのコードの意図や結果がわかりやすくなるこ

    とも、AI による精度の高いコード生成や、質の高いコードレビューに繋がる • 一方で、AI が学習済みのPHPコードはそのほとんどが命令的なコード。学習 量に伴う品質は、命令的なコードの方が高いのでは?という声も • 何を優先し、何を選ぶのかは、あなた次第
  35. まとめ • 宣言的プログラミング とは、How=状態や手続きを抽象化・隠蔽し、 What=定義の宣言を中心とする設計思想やプログラミング手法 • 状態や手続き、制御ロジックが隠蔽されることでコードの意図が明確となるた め、人間はもちろん、AI にとっても可読性の高いコードが書きやすい •

    マルチパラダイム言語であるPHPでも宣言的プログラミングは可能 • パーサーコンビネーター は宣言的プログラミングの代表的な手法のひとつ • パーサーコンビネーターを使えば、複雑なデータ構造や文字列のパースや解析 を、高い可読性を保ちながら実現できる