日常で使うRubyプログラミング

日常の作業でRubyを使う際、気になったことをChatGPTに聞いています。

RubyでのDSL実装例と拡張バッカス・ナウア記法(EBNF)

DSLの仕様を記述する記法である拡張バッカス・ナウア記法(EBNF)と、Rubyで実際にその仕様を実装する方法について聞いてみました。

言語仕様の構成要素として,終端記号 (Terminal symbols)、非終端記号 (Non-terminal symbols)、導出記号 (::=):、選択 (|)、繰り返し ({})、オプション ()、グルーピング (())の7つの要素が組み合わされていたのですね。

いきなりパーサーのコード例を見ても理解できなかったと思います。

実装例は、簡単な方でフォーマットした文書の出力、複雑な方でRubyのテストフレームワークのDSLの例を挙げてもらいました。

DSLの仕様を記述する方法があれば教えてください。

User
DSLの仕様を記述する方法があれば教えてください。

ChatGPT
DSL(Domain Specific Language、ドメイン特化言語)の仕様を記述する方法には、主に以下のようなステップがあります。DSLの設計と実装は、特定の問題領域やタスクに特化した言語を作ることで、そのドメインに関わる作業をより簡単かつ直感的に行えるようにすることを目指します。

1. ドメインの理解
ドメイン分析: 対象となるドメインの深い理解を得る。関連するタスク、ジャーゴン、データ構造などを特定する。
ユーザーとの対話: ドメインの専門家や将来のDSLユーザーと対話し、彼らのニーズと使用する抽象概念を理解する。

2. 抽象概念の特定
抽象概念の選定: ドメイン分析から得られた情報を基に、DSLがカバーすべき主要な概念や操作を選定する。
モデリング: 選定した概念をモデル化し、DSLの抽象的な構造を定義する。

3. 構文の設計
構文の選択: 内部DSLの場合はホスト言語の構文を活用し、外部DSLの場合は独自の構文を設計する。

ユーザビリティ: 構文が直感的で、対象のドメインの専門家にとって自然に感じられるようにする。

例とテストケース: 具体的な使用例やテストケースを作成して、構文の妥当性を検証する。

4. 実装
パーサーとインタプリター(またはコンパイラー)の開発: DSLの構文を解析し、適切な操作を実行できるようにする。

ライブラリとAPI: ドメイン固有の機能を実装し、必要に応じて既存のライブラリやAPIとの統合を行う。

5. ドキュメントとサポート
ドキュメントの作成: 言語の仕様、構文、使用方法についての詳細なドキュメントを用意する。

ユーザーサポート: フォーラムやチャットなどを通じて、ユーザーからのフィードバックを受け取り、サポートを提供する。

6. フィードバックと反復
フィードバックの収集: ユーザーからのフィードバックを積極的に収集し、DSLの改善点を特定する。

反復的な改善: ユーザーのフィードバックや新たなドメインの知見を基に、DSLを継続的に改善し拡張する。

DSLの開発は、しばしば反復的なプロセスです。ドメインの深い理解に基づき、ユーザーのニーズに合わせて設計・実装されるべきです。また、実際の使用シナリオでの検証とユーザーからのフィードバックが、その成功の鍵となります。

パーサーやインタプリターの構文を定義する簡潔な記法を見たことがあるのですが、ご存じでしたら教えてください。

User
パーサーやインタプリターの構文を定義する簡潔な記法を見たことがあるのですが、ご存じでしたら教えてください。

ChatGPT
はい、パーサーやインタプリターの構文を定義する際に使われる簡潔な記法には、「バッカス・ナウア記法(BNF)」やその派生形である「拡張バッカス・ナウア記法(EBNF)」があります。これらの記法は、プログラミング言語やデータ形式の構文を形式的に記述するために広く使われています。

○バッカス・ナウア記法(BNF)
バッカス・ナウア記法(BNF)は、コンピュータ言語の文法を記述するための構文を提供します。BNFは以下の要素で構成されます。

非終端記号: 文法の変数を表し、他の非終端記号や終端記号に展開される。通常、<と>で囲まれます(例: )。

終端記号: 文法の基本要素であり、さらに展開されない記号や文字列。通常、引用符で囲まれるか、そのまま書かれます。

導出記号: 通常は::=を用いて、ある非終端記号が他の記号の列に展開されることを表します。

選択: |記号を使用して選択肢を表し、複数の代替可能な展開を示します。

○拡張バッカス・ナウア記法(EBNF)
拡張バッカス・ナウア記法(EBNF)は、BNFを拡張したもので、より読みやすく柔軟な文法定義を可能にします。EBNFは以下の追加機能を提供します。

オプション: [ ]を使用して、ある要素が任意であることを示します(例: [])。

繰り返し: { }を使用して、ある要素が0回以上繰り返されることを示します(例: {})。

グルーピング: ( )を使用して、要素のグループを作成し、グループ全体に対して操作(選択、繰り返し等)を適用します。

BNFやEBNFは、言語の構文を明確に定義する強力なツールです。これらを利用することで、パーサーやコンパイラの開発者は、言語の正確な文法を形式的に記述し、その文法に基づいてソフトウェアを開発することができます。

EBNF記法について、詳しく教えていただけませんか? 言語での実装例はRubyでDSLを定義する方法(instance_evalを使うもの)でお願いします。

User
僕が見たことがあるのはEBNFの方でした!

この記法について、詳しく教えていただけませんか? 言語での実装例はRubyでDSLを定義する方法(instance_evalを使うもの)でお願いします。

ChatGPT
拡張バッカス・ナウア記法(EBNF)についての詳細と、RubyでDSLを定義する例を説明します。

拡張バッカス・ナウア記法(EBNF)の詳細
EBNFは、コンピュータ言語の文法を記述するのに使われる形式言語です。この記法は、文法の構造を表現するために特別な記号と規則を使用します。基本的な要素は以下の通りです。

終端記号 (Terminal symbols): 文法の基本的な構成要素。通常、文字列や記号そのもので、他に展開されない。

非終端記号 (Non-terminal symbols): 他の非終端記号や終端記号に展開される文法の変数。

導出記号 (::=): 非終端記号が他の記号の列に展開されることを示す。

選択 (|): 複数の代替可能な展開を示す。

繰り返し ({}): 括弧内の要素が0回以上繰り返されることを示す。

オプション (): 括弧内の要素が0回か1回出現することを示す。

グルーピング (()): 要素のグループを作成し、グループ全体に対して操作を適用する。

RubyでのDSLの実装例
Rubyでは、instance_evalメソッドを使用することで、与えられたブロック内でレシーバのコンテキストでコードを評価し、DSLを簡単に実装できます。以下は、シンプルなDSLの例です。

class MyDSL  
 def initialize(&block)  
  instance_eval(&block)  
 end  
  
 def method_missing(name, *args, &block)  
  puts "Called #{name} with #{args.inspect}"  
  if block_given?  
   instance_eval(&block)  
  end  
 end  
end  
  
# DSLを使用する  
MyDSL.new do  
 greeting "Hello", "world"  
 question "how", "are", "you" do  
  response "I'm", "fine", "thanks!"  
 end  
end  

この例では、MyDSLクラス内でinstance_evalを使用しています。これにより、ブロック内で定義されたメソッド呼び出し(この場合はgreetingやquestion)は、MyDSLインスタンスのコンテキストで評価されます。method_missingメソッドをオーバーライドすることで、定義されていないメソッドが呼び出された際にカスタムの処理を実行できるようにしています。このテクニックを利用することで、柔軟なDSLを簡単に作成できます。

この例ではDSLの実装の基本的な仕組みを説明していますが、実際の用途に合わせて、DSL内で利用可能なメソッドや構造をカスタマイズすることが可能です。DSLの設計には、その用途と目的を明確に理解し、使用するドメインに密接に合わせて開発することが重要です。

以上それぞれに対応する実装はどのようになりますか?

User
以上それぞれに対応する実装はどのようになりますか?

ChatGPT
RubyでDSLを定義する際に、EBNFの各要素(終端記号、非終端記号、導出記号、選択、繰り返し、オプション、グルーピング)を模倣する実装方法を考えてみましょう。以下の例では、DSLを用いて簡単な文書を定義するシナリオを想定しています。

終端記号 (Terminal symbols)
終端記号は、DSL内で直接使用される値や記号です。Rubyのメソッド引数やシンボル、文字列などで表現できます。

def text(value)  
 puts value  
end  

非終端記号 (Non-terminal symbols)
非終端記号は、他の記号に展開される要素です。Rubyでは、メソッドの呼び出しを使ってこれを表現できます。

def paragraph(&block)  
 instance_eval(&block)  
end  

導出記号 (::=)
導出記号は、特定の構文規則を定義する際に使用します。Rubyの場合、メソッド定義自体がこの導出を表しています。

def document(title, &block)  
 puts title  
 instance_eval(&block)  
end  

選択 (|)
選択は、複数の代替可能な構文規則から選ぶことを意味します。Rubyの条件分岐を使ってこれを実装できます。

def choice(option)  
 if option == :option1  
  puts "Option 1 chosen"  
 elsif option == :option2  
  puts "Option 2 chosen"  
 end  
end  

繰り返し ({})
繰り返しは、ある要素が0回以上繰り返されることを示します。Rubyでは、配列とブロックを組み合わせてこれを実現できます。

def repeat(items)  
 items.each do |item|  
  text(item)  
 end  
end  

オプション ([])
オプションは、ある要素が0回か1回出現することを示します。Rubyでは、引数にデフォルト値を設定することでこれを表現できます。

def optional_text(value = nil)  
 puts value unless value.nil?  
end  

グルーピング (())
グルーピングは、複数の要素をまとめて1つの単位として扱います。Rubyでは、ブロックを使ってグループ化します。

def group(&block)  
 instance_eval(&block)  
end  

以上のように、Rubyではメソッド定義、ブロック、引数のデフォルト値などを駆使して、EBNFの要素を模倣するDSLを構築できます。DSLの設計では、実際に言語の文法に近い形でコードを書けるように、これらの技術を組み合わせて使用します。

EBNFによるDSLの具体例を教えてください。

User
EBNFによるDSLの具体例を教えてください。

ChatGPT
EBNFを用いたDSL(ドメイン特化言語)の具体例として、シンプルなブログポストを生成するためのDSLを考えてみましょう。このDSLは、ブログのタイトル、著者、日付、そして複数の段落を指定できるように設計します。

まず、このDSLのEBNF記法に基づく文法を定義します。その後、RubyでこのDSLを実装する方法を示します。

EBNF記法によるDSL文法

blogpost ::= title author date paragraph+  
title ::= 'title' ':' string  
author ::= 'author' ':' string  
date ::= 'date' ':' string  
paragraph ::= 'paragraph' ':' string  
string ::= '"' .* '"'  

この文法では、ブログポスト(blogpost)がタイトル(title)、著者(author)、日付(date)、そして一つ以上の段落(paragraph+)から構成されることを定義しています。stringは任意の文字列を表し、ここではダブルクォートで囲まれた任意の文字列を想定しています。

RubyでのDSL実装例

class BlogPost  
 def initialize(&block)  
  @post = {}  
  instance_eval(&block)  
 end  
  
 def title(value)  
  @post[:title] = value  
 end  
  
 def author(value)  
  @post[:author] = value  
 end  
  
 def date(value)  
  @post[:date] = value  
 end  
  
 def paragraph(value)  
  @post[:paragraphs] ||= []  
  @post[:paragraphs] << value  
 end  
  
 def render  
  puts "Title: #{@post[:title]}"  
  puts "Author: #{@post[:author]}"  
  puts "Date: #{@post[:date]}"  
  puts "\nParagraphs:"  
  @post[:paragraphs].each { |para| puts para }  
 end  
end  
  
# DSLを使用する  
BlogPost.new do  
 title "My First Blog Post"  
 author "John Doe"  
 date "2024-03-29"  
 paragraph "This is the first paragraph of the blog post."  
 paragraph "This is the second paragraph."  
end.render  

この実装例では、BlogPostクラス内でinstance_evalを使ってブロック内のコードを評価しています。ここで定義されたメソッド(title, author, date, paragraph)は、EBNF文法で定義された構文規則に相当します。renderメソッドは、ブログポストの内容を出力します。

DSLを使うことで、ブログポストの構造を明確にし、読みやすく書くことができます。また、この例ではシンプルに保つために出力は単純なテキストですが、HTMLやMarkdownなど、さまざまな形式での出力に拡張することが可能です。

おかげでかなりイメージできるようになって来ました。もう少し複雑な例も教えてください。

User
ありがとうございます。おかげでかなりイメージできるようになって来ました。

もう少し複雑な例も教えてください。

ChatGPT
もう少し複雑なDSLの例として、シンプルなテストケースを記述するためのDSLを考えてみましょう。このDSLを使って、テストのセットアップ、複数のテストケース、それぞれのテストケースに対する期待値の指定ができるようにします。

EBNF記法によるDSL文法のイメージ
以下は、このようなテストケースDSLの文法をイメージしたEBNFの例です。

test_suite ::= setup test_case+  
setup ::= 'setup' '{' command* '}'  
test_case ::= 'test' string '{' command* 'expect' string '}'  
command ::= 'execute' string | 'assign' string string  

test_suiteは、セットアップと一つ以上のtest_caseから構成されます。

setupセクションでは、テストの前提条件を設定するためのcommandが0個以上指定できます。

各test_caseは、テストの説明(string)と、実行するcommand、期待する結果(expect後のstring)を含みます。

commandは、executeまたはassignのいずれかで、テストの操作を記述します。

RubyでのDSL実装例

class TestSuite  
 def initialize(&block)  
  @setup_commands = []  
  @test_cases = []  
  instance_eval(&block)  
 end  
  
 def setup(&block)  
  @setup_context = Setup.new  
  @setup_context.instance_eval(&block)  
  @setup_commands = @setup_context.commands  
 end  
  
 def test(description, &block)  
  test_case = TestCase.new(description)  
  test_case.instance_eval(&block)  
  @test_cases << test_case  
 end  
  
 def run  
  puts "Running tests..."  
  @test_cases.each_with_index do |test_case, index|  
   puts "Test #{index + 1}: #{test_case.description}"  
   @setup_commands.each(&:execute)  
   test_case.run  
   puts ""  
  end  
 end  
  
 class Setup  
  attr_reader :commands  
  
  def initialize  
   @commands = []  
  end  
  
  def execute(cmd)  
   @commands << Command.new(cmd)  
  end  
 end  
  
 class TestCase  
  attr_reader :description, :expectation, :commands  
  
  def initialize(description)  
   @description = description  
   @commands = []  
  end  
  
  def execute(cmd)  
   @commands << Command.new(cmd)  
  end  
  
  def expect(result)  
   @expectation = result  
  end  
  
  def run  
   puts "Commands:"  
   @commands.each(&:execute)  
   puts "Expected: #{@expectation}"  
  end  
 end  
  
 class Command  
  def initialize(command)  
   @command = command  
  end  
  
  def execute  
   puts "Executing: #{@command}"  
  end  
 end  
end  
  
# DSLを使用する  
TestSuite.new do  
 setup do  
  execute "Initialize database"  
 end  
  
 test "Addition works" do  
  execute "Sum = add(1, 2)"  
  expect "Sum == 3"  
 end  
  
 test "Subtraction works" do  
  execute "Difference = subtract(5, 3)"  
  expect "Difference == 2"  
 end  
end.run  

この例では、TestSuiteクラス内でDSLを定義しています。setupメソッドではテスト実行前のセットアップを行い、testメソッドでは個々のテストケースとその期待値を指定します。実際のテストケース実行ロジック(ここでは単にコマンドの出力を模擬しています)は、実際のテストフレームワークを使用するか、もしくはこのDSLの背後にあるロジックによって補完される必要があります。

このようにDSLを使用すると、特定のドメインに特化したテストケースの記述がより簡単に、そして読みやすくなります。DSLを通じて、テストロジックをより明確に分離し、テストケースの意図を直感的に伝えることができます。

ありがとうございます。こちらはかなり難しく感じます。解説をお願いして良いですか?

User
ありがとうございます。こちらはかなり難しく感じます。解説をお願いして良いですか?

ChatGPT
もちろんです。上記のRubyでのDSL実装例は、テストスイートを記述するためのドメイン特化言語を示しています。このDSLを使って、テストのセットアップ、複数のテストケース、およびそれぞれのテストケースの期待値を定義できます。ここでは、その実装をステップバイステップで解説します。

○TestSuiteクラス
このクラスはDSLのエントリーポイントです。テストスイート全体を表し、セットアップとテストケースの定義を可能にします。

initializeメソッド: コンストラクタはブロックを受け取り、instance_evalを使ってこのブロックを評価します。これにより、ブロック内のメソッド呼び出しがTestSuiteインスタンスのコンテキスト内で行われるようになります。

setupメソッド: セットアップブロックを定義します。内部でSetupクラスのインスタンスを作成し、そのインスタンスでブロックを評価します。セットアップで実行されるコマンドは、@setup_commands配列に保存されます。

testメソッド: テストケースを定義します。各テストケースはTestCaseクラスのインスタンスとして表され、ブロック内のコマンドはこのインスタンスで評価されます。

runメソッド: 定義されたテストケースを実際に実行します。セットアップコマンドを最初に実行し、続いて各テストケースを実行します。

○Setupクラス
テストのセットアップを担当します。セットアップで実行されるべきコマンドを格納するための配列を持ちます。

initializeメソッド: コマンドを格納する配列を初期化します。

executeメソッド: 実行されるコマンドをcommands配列に追加します。

○TestCaseクラス
個々のテストケースを表します。テストの説明、実行されるべきコマンドのリスト、期待値を持ちます。

initializeメソッド: テストケースの説明を設定します。

executeメソッド: テストケースで実行されるコマンドを追加します。

expectメソッド: テストケースの期待値を設定します。
runメソッド: テストケースのコマンドを実行し、期待値を表示します。

○Commandクラス
実行されるべき個々のコマンドを表します。このクラスは非常にシンプルで、コマンド文字列を保持し、実行時にそれを表示するだけです。

initializeメソッド: コマンド文字列を設定します。

executeメソッド: コマンド文字列を表示します。

DSLの使用例

TestSuite.new do  
 setup do  
  execute "Initialize database"  
 end  
  
 test "Addition works" do  
  execute "Sum = add(1, 2)"  
  expect "Sum == 3"  
 end  
  
 test "Subtraction works" do  
  execute "Difference = subtract(5, 3)"  
  expect "Difference == 2"  
 end  
end.run  

このコードは、TestSuiteクラスのインスタンスを作成し、ブロック内でテストスイートのセットアップと2つのテストケースを定義しています。最後に、runメソッドを呼び出してテストケースを実行します。このDSLを通じて、テストの定義が直感的かつ宣言的になり、テストコードの可読性とメンテナンス性が向上します。

上記DSLが一行ずつ、どこでどう実行されているかも解説お願いします。

User
上記DSLが一行ずつ、どこでどう実行されているかも解説お願いします。

ChatGPT
DSLの各行がどのように実行されるか、具体的に追ってみましょう。

TestSuite.new do  

この行ではTestSuiteクラスの新しいインスタンスが作成され、与えられたブロックがinstance_evalを通じてこのインスタンスのコンテキストで実行されます。これにより、setupやtestなどのメソッド呼び出しがTestSuiteインスタンスに対して直接行われます。

 setup do  
  execute "Initialize database"  
 end  

setupメソッドが呼び出され、その引数として渡されたブロックがSetupクラスのインスタンスのコンテキストで実行されます。executeメソッドはSetupクラスに定義されており、"Initialize database"という文字列をコマンドリストに追加します。

 test "Addition works" do  
  execute "Sum = add(1, 2)"  
  expect "Sum == 3"  
 end  

testメソッドが呼び出され、"Addition works"という説明と、実行するコマンドと期待値を含むブロックが引数として渡されます。このブロックはTestCaseクラスのインスタンスのコンテキストで実行されます。executeはコマンドリストに"Sum = add(1, 2)"を追加し、expectは期待値"Sum == 3"を設定します。

 test "Subtraction works" do  
  execute "Difference = subtract(5, 3)"  
  expect "Difference == 2"  
 end  

同様に、二つ目のテストケースが定義されます。"Subtraction works"という説明に対し、"Difference = subtract(5, 3)"を実行し、期待値として"Difference == 2"が設定されます。

end.run  

endでTestSuiteインスタンスのブロックが終了し、runメソッドが呼び出されます。runメソッドでは、まずセットアップ段階のコマンドが実行され(この例では"Initialize database")、その後で各テストケースが順に実行されます。各テストケースでは、コマンドが実行され、期待値が表示されます。

この一連のプロセスを通じて、DSLはテストの定義と実行を簡潔かつ直感的に行うことができます。instance_evalを使用することで、各ブロック内のコンテキストを適切なオブジェクト(TestSuite、Setup、TestCaseのインスタンス)に切り替え、そのオブジェクトのメソッドをブロック内で直接呼び出すことができるようになります。これにより、自然言語に近い形でテストスイートを記述することが可能になります。

かなり理解が進みました。しかし、Commandを実行するまでがまだよく分かりません。

User
ありがとうございます。かなり理解が進みました。しかし、Commandを実行するまでがまだよく分かりません。

ChatGPT
Commandクラスの実行について、もう少し詳しく見てみましょう。Commandクラスは、実行するべき個別のコマンドを表すところから始まります。このクラスは非常にシンプルで、コマンド文字列を保持し、それを実行時に表示する機能のみを持っています。

DSLのコンテキストで「コマンドの実行」と言った場合、実際にはCommandインスタンスが保持する文字列を処理(この例では単に出力)することを指します。DSLのユーザー(テストケースの記述者)は、executeメソッドを使って、何らかの操作を表す文字列を指定します。この操作は、テストケースが実際に実行される際に、Commandインスタンスによってシミュレートされます。

Commandクラスの定義と実行

class Command  
 def initialize(command)  
  @command = command  
 end  
  
 def execute  
  puts "Executing: #{@command}"  
 end  
end  

Commandクラスには、initializeメソッドでコマンド文字列を受け取り、executeメソッドでその文字列を出力する機能があります。

Commandの利用
Commandのインスタンスは、SetupやTestCaseクラス内のexecuteメソッドを通じて作成されます。例えば、TestCaseクラス内でexecuteメソッドが呼ばれると、指定されたコマンド文字列を持つ新しいCommandインスタンスが作成され、テストケースのコマンドリストに追加されます。

class TestCase  
 def execute(cmd)  
  @commands << Command.new(cmd)  
 end  
  
 def run  
  puts "Commands:"  
  @commands.each(&:execute)  
 end  
end  

コマンドの実行プロセス
TestCaseインスタンスのrunメソッドが呼び出されると、そのインスタンスが保持するすべてのCommandインスタンスのexecuteメソッドが順に呼び出されます。

Commandインスタンスのexecuteメソッドは、単にそのコマンド文字列を出力します。この出力は、テストケースの一環として特定の操作が「実行される」ということをシミュレートしています。

このプロセスを通じて、DSLのユーザーはexecuteメソッドを使って任意の操作を「実行」できると表現しますが、実際の「実行」は、Commandインスタンスがその文字列を出力することでシミュレートされています。このシンプルな実装では、実際に外部システムを操作したり、計算を実行したりすることはありませんが、この仕組みを利用して、実際のテストフレームワークや外部システムとのインタフェースなど、より複雑な機能を実装する基盤とすることができます。

User
ありがとうございます。おかげで理解できました。

ChatGPT
それは良かったです!もし他に質問やさらに知りたいことがあれば、いつでもお知らせください。理解を深めるために、実際にコードを書いてみるのも良い方法ですよ。