簡易な正規表現エンジンを実装する
GA technologiesでソフトウェアエンジニアをしている中坂です。
先日社内で正規表現エンジンの実装の話題で盛り上がりました。ソフトウェアエンジニアであれば普段の開発の中では当たり前のように正規表現を使っていると思いますので、記法や挙動に関する知識はある程度持っているはずです。しかしながら、具体的に我々の書いた正規表現がいかにしてテキストにマッチするのか、その内部の仕組みまで理解している人は多くないと思います。
今回は「正規表現エンジン」の実装を実際に示しながら、普段当たり前のように書いている正規表現がどのように処理されているのかを深掘っていきます。
全体の流れ
この記事は少々長いので、まず最初に正規表現エンジンを実装するまでの流れをザッと書いておきます。
はじめは正規表現エンジンを理解するために必要な基礎知識として有限オートマトンの概念を説明します。有限オートマトンには主に決定性有限オートマトン(DFA)と非決定性有限オートマトン(NFA)の2種類がありますが、今回はNFAを使って実装を進めます。
次に正規表現とNFAの関係について説明します。正規表現はNFAに変換できることが知られています。この変換手法としてThompsonの構成法を用います。この手法を理解することでNFAと正規表現の関係がより深く理解できるはずです。
一通り必要な解説を行った後、正規表現エンジンの実装を以下の5つのステップで進めていきます。
- NFAの実装
- 状態と状態遷移を表現するクラスを作成
- 字句解析器(レキサー)の実装
- 正規表現文字列をトークン列に分解
- 構文解析器(パーサー)の実装
- トークン列を抽象構文木(AST)に変換
- ASTからNFAへの変換
- Thompsonの構成法を使ってASTをNFAに変換
- マッチング処理の実装
- NFAを使ってテキストが正規表現にマッチするか判定
実装はRubyで行います。入力文字は ASCII文字のアルファベットと記号(\
以外) を受け入れ、機能としては和集合・連接・連接閉包(正規表現記号としては|
・*
・(
・)
のみ)の完全一致のパターンに絞り、正規表現エンジンの理解しやすさに焦点を当てます。
有限オートマトンとは
有限オートマトンは、記号列を1文字ずつ読み込みながら状態を遷移させ、最終的にその記号列を受理するか拒否するかを判定する計算モデルです。
有限オートマトンは以下の要素で構成されます。
- 状態の有限集合
- オートマトンが取りうる全ての状態
- 入力となる有限のアルファベット
- 処理対象となる文字の集合
- 状態遷移関数
- 現在の状態と入力文字から次の状態を決定するルール
- 初期状態
- オートマトンの開始状態
- 受理状態の集合
- 文字列が受理される状態
厳密な定義を理解出来ずとも、直感的には下記のような状態遷移図で表されるものとして理解しておけば良いです。
例えばこの遷移図はq0やq1などの丸が状態を表し、左の矢印から始まります。aなどの文字が書いてある矢印はその入力文字によってどの状態に遷移するかを表し、二重丸は受理状態の状態を表しています。最後に辿り着いた場所が二重丸であれば受理されます。
なのでこの遷移図の場合、a
やab
やabb
やabbbbbbbbbb
を受理しますがb
やba
は拒否されます。
ネタバレになってしまうのですが、これはつまり正規表現としてはab*
と書けます。「aから始まりbを0個以上含む文字列の集合」を表現しているわけですね。
DFAとNFA
有限オートマトンには主に2つの種類があります。
DFA(Deterministic Finite Automaton: 決定性有限オートマトン) は、ある状態である文字を読んだとき、次の状態が必ず一意に決まります。つまり、どの時点でも次に進む道が1つしかありません。
一方、NFA(Non-deterministic Finite Automaton: 非決定性有限オートマトン) は、同じ状態で同じ文字を読んでも複数の遷移先を持つことができます。下記の図ではq0
の時にaがq0
とq1
の2パターンの遷移の可能性があります。
さらに、NFAにはε遷移(イプシロン遷移)と呼ばれる、入力を消費せずに状態を遷移できる特殊な遷移があります。例えば下記の遷移図ではε
の部分は入力文字に関わらず自由に遷移して良いので一気にq2
まで遷移できます。なのでac
やbc
やabc
が受理されるのはもちろんc
だけでも受理されます。
NFAは各状態で一意に遷移先が決まらないので一見すると複雑に見えますが、結局は有限なので実装上は「取りうる全ての状態」を同時に追跡することで処理できます。
厳密な証明は省きますが、なんとDFAとNFAは表現能力が等価です。つまりどんなNFAもDFAに変換でき、逆もまた真です(詳しくは計算理論とオートマトン言語理論[第2版]などの教科書を読んでください)。
NFAをゴニョゴニョ書き換えるとDFAになるし、DFAをゴニョゴニョするとNFAにもできるというのはなんとも驚きですね...。
正規表現とNFA
ここで今まで当たり前のように使ってきた正規表現をもう少し具体的に表現すると、正規表現とは入力アルファベットの記号に3種類の演算を次々と施してできる式であり、3種類の演算とは和集合・連接・連接閉包です。
お馴染みの正規表現として書き下した方がわかりやすいと思うのでそれぞれ例を挙げてみると下記です。
- 和集合
-
a|b
(a
という文字もしくはb
という文字というor的な処理)
-
- 連接
-
ab
(a
という文字とb
という文字を繋げる処理)
-
- 連接閉包(クリーネ閉包と言ったりもする)
-
a*
(a
という文字の0以上の連続を表す処理)
-
この正規表現で表現される式が実は有限オートマトンと等価であることが知られています(詳しくは計算理論とオートマトン言語理論[第2版]などの教科書を読んでください)。
ですので今回正規表現エンジンを実装する際は、任意の正規表現を取り、それをNFAに変換することができれば、あとは入力文字列をNFAに流し込めば受理or拒否が判定でき、いわゆる正規表現によるマッチングの判定ができるという算段になります。
Thompsonの構成法
正規表現をNFAに変換する代表的な手法がThompsonの構成法です。この方法により作成されるNFAは全て開始状態と受理状態を持ち、正規表現の構造に対応する形でそれらNFAを再帰的に組み合わせて全体のNFAを構築します。
正規表現の基本要素に対応する各種NFAの構築ルールは以下の通りです。各種正規表現と状態遷移図も併記しました。図の作り方が下手すぎて見づらいと思いますが、実装を読むと実際に左側の正規表現を右側の状態遷移図へと変換していることが理解できるはずです。
1. 単一の文字
開始状態から文字で遷移して受理状態に到達する2状態を持つNFAです。r
といった単一の文字を表せます。
1文字ですが開始状態と受理状態を持つNFAであるという点が重要なポイントです。
文字は直感的には状態が1つしか無いように思えてしまうのですが、このように実装することで以下の2~4における演算時に受理状態を別の状態の開始状態に接続してまた別のNFAを構築する、といったレゴブロックのようなことができるパーツとして取り回しやすくなります。
自分は実装時にここの理解が甘くて少し苦労しました。
2. 連接
r1
のNFAの受理状態からr2
のNFAの開始状態へ接続します。r1r2
といった2文字以上の文字を表現できます。
3. 和集合
新しい開始状態からr1
とr2
のNFAへε遷移で分岐し、両者の受理状態を新しい受理状態へε遷移で統合します。
4. 連接閉包
新しい開始状態からr
のNFAへε遷移、r
のNFA受理状態から自身の開始状態へε遷移(1回以上の繰り返し)、さらに新しい開始状態から直接受理状態へのε遷移(0回の繰り返し)です。
この構築ルールに従うことで、複雑な正規表現も段階的にNFAに変換できます。
とはいえ実際のコードを見てみないとイメージがつかないと思うので、以降ではいよいよ実装へと進みます。
実装の流れ
正規表現エンジンの実装は、以下の5つのステップに分けられます。
- 字句解析器の実装
正規表現の文字列を意味のあるトークンに分解します。例えば(
はLEFT_PAREN
、*
はREPEAT
といった具合です。 - 構文解析器の実装
トークン列を解析して抽象構文木(AST)を構築します。演算子の優先順位は*
> 連接 >|
で処理します。 - NFAの実装
NFAのデータ構造(State、遷移関数など)を実装します。 - ASTからNFAへの変換
Thompsonの構成法を使って、ASTの各ノードに対応するNFAを再帰的に構築します。 - マッチング処理の実装
構築したNFAを使って、入力テキストがパターンにマッチするかを判定します。NFAの特性上、取りうる全ての状態を同時に追跡します。
以降では、実際のRubyコードを示しながら各ステップの詳細を解説していきます。
ちなみに1と2は言語処理系の実装の知識を前提としています。正規表現とは直接関係ないので解説は最小限にとどめています。もし正規表現に限った実装だけに興味がある人は3以降に進んでください。
字句解析の実装
字句解析器(レキサー)は正規表現の文字列を読み込んで、意味のある単位(トークン)に分解する役割を担います。
トークンの定義
まず、トークンを表現するクラスを定義します。
class Token
attr_accessor :type, :value
def initialize(type, value)
@type = type
@value = value
end
def to_s
"#{@type}: #{@value}"
end
end
トークンはtype
(トークンの種類)とvalue
(値)を持ちます。例えば、文字a
はtype
が:char
で value:
が'a'
というトークンになります。
レキサーの実装
次に、レキサーを実装します。
class Lexer
SPECIAL_CHARS = {
'|' => :or,
'*' => :repeat,
'(' => :left_paren,
')' => :right_paren,
}
def initialize(pattern)
@pattern = pattern
@pos = 0
end
def tokenize
tokens = []
while @pos < @pattern.length
char = @pattern[@pos]
if SPECIAL_CHARS.key?(char)
tokens << Token.new(SPECIAL_CHARS[char], nil)
else
tokens << Token.new(:char, char)
end
@pos += 1
end
tokens << Token.new(:eof, nil)
tokens
end
end
レキサーは正規表現文字列を1文字ずつ読み込み、特殊文字(|
, *
, (
, )
)を対応するトークンタイプに変換します。それ以外の文字は:char
タイプのトークンとして扱います。最後に:eof
トークンを追加して、パーサーが文字列の終わりを認識できるようにしています。
ここで定義したトークンのタイプ名などに決まったルールはないので自前で勝手に決めて定義して問題ないです。
このレキサーがうまく動くと、例えば正規表現(a|b)*c
を字句解析すると、以下のような感じでトークン列が生成されます。
lexer = Lexer.new("(a|b)*c")
tokens = lexer.tokenize
tokens.each { |token| puts token }
# left_paren:
# char: a
# or:
# char: b
# right_paren:
# repeat:
# char: c
# eof:
このトークン列が、次のステップであるパーサーへの入力となります。
構文解析器の実装
構文解析器(パーサー)は、レキサーが生成したトークン列を解析して、抽象構文木(AST: Abstract Syntax Tree)を構築します。ここで出力されるASTは正規表現の構造を木構造で表現したものです。
再帰下降構文解析
今回のパーサーでは再帰下降構文解析(Recursive Descent Parsing)という手法を使います。これは文法規則を再帰的な関数呼び出しで表現する、シンプルで理解しやすい構文解析手法です。
これもRui Ueyamaさんの低レイヤを知りたい人のためのCコンパイラ作成入門の文法の記述方法と再帰下降構文解析が参考になります。
ASTノードの定義
まず、ASTのノードを表現するクラスを定義します。
class ASTNode
attr_accessor :type, :value, :left, :right
def initialize(type, value, left = nil, right = nil)
@type = type
@value = value
@left = left
@right = right
end
def to_s
case @type
when :char
value
when :concat
"#{@left}#{@right}"
when :or
"(#{@left}|#{@right})"
when :repeat
"#{@left}*"
end
end
end
パーサーの実装
次に、パーサー本体を実装します。
class Parser
def initialize(tokens)
@tokens = tokens
@pos = 0
end
def parse
result = parse_or
if current_token.type != :eof
raise "Failed to parse"
end
result
end
private
def current_token
@tokens[@pos] || Token.new(:eof, nil)
end
def consume(expected_type = nil)
token = current_token
if expected_type && token.type != expected_type
raise "Expected #{expected_type} but got #{token.type}"
end
@pos += 1
token
end
# 和集合(|)の解析: 最も優先順位が低い
def parse_or
left = parse_concat
while current_token.type == :or
consume(:or)
right = parse_concat
left = ASTNode.new(:or, nil, left, right)
end
left
end
# 連接の解析
def parse_concat
nodes = []
# 文字か左括弧が続く限り、ノードを収集
while [:char, :left_paren].include?(current_token.type)
nodes << parse_repeat
end
return nil if nodes.empty?
# 収集したノードを左結合で連結
nodes.reduce do |left, right|
ASTNode.new(:concat, nil, left, right)
end
end
# 連接閉包(*)の解析
def parse_repeat
node = parse_atom
while current_token.type == :repeat
consume(:repeat)
node = ASTNode.new(:repeat, nil, node, nil)
end
node
end
# 基本要素(文字または括弧)の解析: 最も優先順位が高い
def parse_atom
case current_token.type
when :char
char = consume(:char)
ASTNode.new(:char, char.value, nil, nil)
when :left_paren
consume(:left_paren)
node = parse_or
consume(:right_paren)
node
else
raise "Unexpected token: #{current_token.type}"
end
end
end
この実装では例えば(a|b)*c
というトークン列を解析すると、雑ですが以下のようなASTが構築されます。
concat
/ \
repeat c
|
or
/ \
a b
この木構造は、「aまたはbの0回以上の繰り返しの後にcが続く」という正規表現の意味を正確に表現しています。
パーサーがparse
関数を呼び出すと、まずparse_or
が呼ばれ、それがparse_concat
を呼び、さらにparse_repeat
、parse_atom
, parse_or
...と再帰的に呼び出されていきます。この再帰呼び出しの連鎖により、演算子の優先順位が自然に処理されます。
NFAの実装
正規表現の式がASTへと変換できたので次はNFAを実装していきます。
状態とNFAの基本クラス
まず、NFAの状態を表現するState
クラスを定義します。状態遷移図でいうと丸いやつとその丸から伸びる矢印と文字です。
class State
attr_accessor :transitions, :is_final
def initialize
@transitions = Hash.new { |hash, key| hash[key] = [] }
@is_final = false
end
def add_transition(char, state)
@transitions[char] << state
end
def get_transitions(char)
@transitions[char]
end
end
State
クラスは、遷移先の状態を文字ごとにHashで管理します。nil
をキーとする遷移はε遷移を表します(このアイデアはアンダースタンディング コンピュテーション ―単純な機械から不可能なプログラムまでという本を参考にしています。多謝🙏)。is_final
フラグは、この状態が受理状態かどうかを示します。
次に、NFAそのものを表現するNFA
クラスを定義します。
class NFA
attr_accessor :start, :accept
def initialize(start_state, accept_state)
@start = start_state
@accept = accept_state
end
end
NFAは開始状態と受理状態を持つだけのシンプルな構造です。先に解説した通り、Thompsonの構成法では各NFAが必ず1つの開始状態と1つの受理状態を持つという性質を利用します。
各種NFAの文字と演算の実装
単一文字のNFA
class Char < NFA
attr_accessor :char
def initialize(char)
@char = char
start = State.new
accept = State.new
accept.is_final = true
start.add_transition(@char, accept)
super(start, accept)
end
def to_s
"#{@char}"
end
end
単一文字のNFAは、開始状態から単一文字で遷移して受理状態に到達する、最もシンプルな2状態のNFAです。
連接のNFA
class Concat < NFA
attr_accessor :left, :right
def initialize(left, right)
@left = left
@right = right
@left.accept.is_final = false
@left.accept.add_transition(nil, @right.start)
super(@left.start, @right.accept)
end
def to_s
"#{@left}#{@right}"
end
end
連結ab
のNFAは、a
のNFAの受理状態からb
のNFAの開始状態へε遷移で接続します。これにより、「aを読んでからbを読む」という動作が実現されます。
和集合のNFA
class Or < NFA
attr_accessor :nfa1, :nfa2
def initialize(nfa1, nfa2)
@nfa1 = nfa1
@nfa2 = nfa2
start = State.new
accept = State.new
accept.is_final = true
@nfa1.accept.is_final = false
@nfa1.accept.add_transition(nil, accept)
@nfa2.accept.is_final = false
@nfa2.accept.add_transition(nil, accept)
start.add_transition(nil, @nfa1.start)
start.add_transition(nil, @nfa2.start)
super(start, accept)
end
def to_s
"(#{@nfa1}|#{@nfa2})"
end
end
選択a|b
のNFAは、新しい開始状態からa
とb
のNFAへε遷移で分岐します。どちらかのパスが成功すれば全体が成功します。
連接閉包のNFA
class Repeat < NFA
attr_accessor :nfa
def initialize(nfa)
@nfa = nfa
start = State.new
accept = State.new
start.add_transition(nil, accept)
start.add_transition(nil, @nfa.start)
@nfa.accept.is_final = false
@nfa.accept.add_transition(nil, @nfa.start)
@nfa.accept.add_transition(nil, accept)
accept.is_final = true
super(start, accept)
end
def to_s
"#{@nfa}*"
end
end
これらのクラスを組み合わせることで、任意の正規表現をNFAに変換できます。
例えば(a|b)*c
というパターンは、Repeat(Or(Char('a'), Char('b')))
とChar('c')
をConcat
で結合して表現されます。
ASTからNFAへの変換
パーサーが構築したASTをThompsonの構成法を使ってNFAに変換します。この変換は、ASTを深さ優先探索で走査しながら、各ノードに対応するNFAを再帰的に構築することで実現できます。
ASTToNFAクラスの実装
class ASTToNFA
def build(ast)
return nil if ast.nil?
case ast.type
when :char
build_char(ast)
when :concat
build_concat(ast.left, ast.right)
when :or
build_or(ast.left, ast.right)
when :repeat
build_repeat(ast.left)
else
raise "Unexpected type: #{ast.type}"
end
end
private
def build_char(ast)
Char.new(ast.value)
end
def build_concat(left, right)
left_nfa = build(left)
right_nfa = build(right)
Concat.new(left_nfa, right_nfa)
end
def build_or(left, right)
left_nfa = build(left)
right_nfa = build(right)
Or.new(left_nfa, right_nfa)
end
def build_repeat(left)
left_nfa = build(left)
Repeat.new(left_nfa)
end
end
このクラスは非常にシンプルです。ASTの各ノードタイプに対応するbuild_*
メソッドを呼び出し、それぞれが対応するNFAクラス(Char
、Concat
、Or
、Repeat
)のインスタンスを返します。
例えば、(a|b)*c
のAST(Concat(Repeat(Or(Char('a'), Char('b'))), Char('c'))
)は以下のように変換されます。
- ルートの
concat
ノードを処理 - 左側の子ノード
repeat
を処理- その子ノード
or
を処理-
a
のChar NFAを構築 -
b
のChar NFAを構築 - これらをOr NFAで結合
-
- Or NFAをRepeat NFAで包む
- その子ノード
- 右側の子ノード
c
を処理-
c
のChar NFAを構築
-
- Repeat NFAとChar NFAをConcat NFAで結合
この再帰的な処理により、複雑な正規表現が段階的にNFAに変換されていきます。
レキサー、パーサー、ASTからNFAへの変換を組み合わせると、下記のような実装で正規表現文字列からNFAを構築できます。
tokens = Lexer.new("(a|b)*c").tokenize
ast = Parser.new(tokens).parse
nfa = ASTToNFA.new.build(ast)
これで正規表現(a|b)*c
が完全なNFAに変換されます。あとはこのNFAを使ってテキストのマッチング処理を実装すれば、正規表現エンジンが完成します。
マッチング処理の実装
いよいよ最後のステップです。構築したNFAを使って入力テキストが正規表現にマッチするかを判定します。
NFAの非決定性とε閉包
NFAは非決定性を持つため、ある時点で複数の状態を同時に取りうることを思い出してください。さらにε遷移により入力を消費せずに複数の状態に到達できます。
そのためマッチング処理では以下を実装します。
- ε閉包の計算
- ある状態集合からε遷移だけで到達可能な全ての状態を計算
- 状態集合の管理
- 各文字を読むたびに現在取りうる全ての状態を追跡
- 受理判定
- 最終的な状態集合に受理状態が含まれていれば文字列を受理
Evaluatorクラスの実装
class Evaluator
attr_accessor :nfa
def initialize(nfa)
@nfa = nfa
end
def match?(text)
return false if @nfa.nil?
current_states = epsilon_closure([@nfa.start])
text.each_char do |char|
next_states = Set.new
current_states.each do |state|
state.get_transitions(char).each do |next_state|
next_states.add(next_state)
end
end
current_states = epsilon_closure(next_states)
return false if current_states.empty?
end
current_states.any?(&:is_final)
end
private
def epsilon_closure(states)
closure = Set.new(states)
stack = states.to_a
while !stack.empty?
state = stack.pop
state.get_transitions(nil).each do |next_state|
unless closure.include?(next_state)
closure.add(next_state)
stack.push(next_state)
end
end
end
closure
end
end
epsilon_closure
メソッドは、スタックを使った探索でε遷移で遷移可能な全ての状態を見つけ出します。これにより、入力を消費せずに到達可能な全ての状態を効率的に列挙できます。
例えば、a*
のNFAでは、開始状態からε遷移で複数の状態に同時に存在できるため、ε閉包の計算が不可欠です。
完成した正規表現エンジン
これまでの全てのコンポーネントを統合すると、完全な正規表現エンジンが完成します。
class RegexEngine
def initialize(pattern)
@pattern = pattern
compile
end
def match?(text)
Evaluator.new(@nfa).match?(text)
end
private
def compile
tokens = Lexer.new(@pattern).tokenize
ast = Parser.new(tokens).parse
@nfa = ASTToNFA.new.build(ast)
end
end
使用例
engine1 = RegexEngine.new("(a|b)*c")
puts engine1.match?("c") # => true
puts engine1.match?("ac") # => true
puts engine1.match?("bc") # => true
puts engine1.match?("abc") # => true
puts engine1.match?("aabbc") # => true
puts engine1.match?("ab") # => false
puts engine1.match?("d") # => false
engine2 = RegexEngine.new("a|b|c")
puts engine2.new("a|b|c").match?("a") # => true
puts engine2.new("a|b|c").match?("b") # => true
puts engine2.new("a|b|c").match?("c") # => true
puts engine2.new("a|b|c").match?("ab") # => false
puts engine2.new("a|b|c").match?("ac") # => false
puts engine2.new("a|b|c").match?("bc") # => false
puts engine2.new("a|b|c").match?("abc") # => false
engine3 = RegexEngine.new("a(b|c)")
puts engine3.match?("ab") # => true
puts engine3.match?("ac") # => true
puts engine3.match?("bc") # => false
puts engine3.match?("abc") # => false
puts engine3.match?("a") # => false
puts engine3.match?("b") # => false
puts engine3.match?("c") # => false
正規表現が概ね正しく動作していることが確認できました!!!!!
今回実装した完全なソースコードは以下になります。
最後に
この記事では簡単な正規表現エンジンを一から実装することでオートマトンと正規表現の関係や正規表現を文字列にマッチさせる仕組みを実践的に理解しました。
今回は*
や|
といった基本的な記号しか実装しなかったですが+
や?
もこの実装の延長線上にあるので追加して自作することもできます。
とはいえここまでで学んだ知識だけで実用的な正規表現エンジンが実装できるかというとそうではないです。例えば現代的なプログラミング言語に搭載されている正規表現エンジンではざっとリストアップするだけでも下記のような高度な機能があります。
- 文字クラス(
[a-z]
、\d
など) - 量化子(
+
、?
、{n,m}
など) - キャプチャグループ
- 後方参照
- 行頭/行末のアンカー(
^
、$
) - 最長一致と最短一致
これらの機能の中には今回実装したNFA型の正規表現エンジンでは実現できないものもあります(例えば後方参照。NFAでは過去のマッチ結果を再度参照することが理論上できません。NFAはチョムスキー階層でいうところの正規言語しか表現できないですが、後方参照は文脈自由文法の範囲。詳しくはリンクの教科書を読んでください。)。
ではどのように実装するのかというと、一種のVMを実装し、そのVMのバイトコードに正規表現をコンパイルしてからVM内でマッチングを行うというVM型の正規表現エンジンを使います。このマッチングではパターンにマッチするまでバックトラッキングという手法を使いながらマッチするまで全探索させます。(詳しくはRegular Expression Matching: the Virtual Machine Approachを読んでください)。この仕組みがいわゆるReDoSに繋がったりします。
今回はシンプルな正規表現エンジンの実装に絞りましたが、このように正規表現エンジンから派生してまだまだ解説できていない技術や分野がたくさん広がっています。詳しくは計算理論や形式言語理論の教科書などを読んでみてください。
個人的にはまずアンダースタンディング コンピュテーション ―単純な機械から不可能なプログラムまでをお勧めします。今回実装したようなオートマトンからチューリングマシンや停止問題などの理論的な話がRubyによる実装を以て解説されており非常に理解しやすいです(特にラムダ計算だけでFizzBuzzコードを書いている章は圧倒されます...)。
GA technologiesではこのように普段当たり前のように利用している技術を自作して深くまで学ぶことができる心意気のあるソフトウェアエンジニアを求めています。もし興味があれば下記のリンク、もしくは私のXのアカウントまでご連絡ください。
Discussion