今そこにある“DSL”

第2回内部DSLへの

今回は内部DSLの詳細、またその実践としてRubyを例にした実装について解説します。

内部DSLに適した言語 - Ruby

2005年12月にRuby on Railsの正式版のリリース以降、そのフレームワークのみならず、プログラミング言語Rubyを支持、採用するプログラマーが増えてきました。

そのことを端的に表しているのが、達人クラスのプログラマー、そしてアーキテクトの存在です。一人は『達人プログラマー[1]⁠プログラミングRuby[2]⁠』などの著書で知られているDave Thomas氏です。もう一人は『エンタープライズ アプリケーションアーキテクチャパターン[3]⁠』などで著名なアーキテクト Martin Fowler氏です。Fowler氏が属している会社 - ThoughtWorks社[4]の多くのプロジェクトは、Rubyで開発していると聞いてます。

なぜ、彼等は、Rubyを支持しているのでしょうか?

私は、実際に彼等の言葉を聞いたわけではありませんが、Rubyで強力なDSLを作ることができるからではないか、と推測してます。

Dave Thomas氏は、その著書 - 達人プログラマーの§12.専用言語で、⁠問題領域の解決にはその領域の言語を前提に置くべきであり、そこからプログラミング上の解決策を引き出す事が重要である」と述べてます。そのことから、DSLに造詣が深いことを伺い知ることができます。

Rubyで作られるDSLの多くは、内部DSLです。その主なものとして、Rails ActiveRecord, Rails Validation, Rake(Make Ruby), RSpec(テスティングツール), Capistrano(デプロイツール)などがあります。

リスト1 内部DSLを使って実装されている代表的なもの

a. Ruby on Rails

class Library < ActiveRecord::Base
  has_many :books
  validates_associated :books
end

b.Rake

task :default => [:test]

task :test do
  ruby "test/unittest.rb"
end

c.RSpec

describe Bowling do
  it "should score 0 for gutter game" do
    bowling = Bowling.new
    20.times { bowling.hit(0) }
    bowling.score.should == 0
  end
end

d.Capistrano

role :libs, "www.gihyo.jp"
task :search_libs do
  run "ls -x1 /usr/lib | grep -i xml"
end

また、Rubyと同じように内部DSLに適した言語には、⁠プログラム可能なプログラミング言語」と言われているLisp、メタプログラミングに適し、文法までも拡張することができるSmalltalkがあります。

その一方で、内部DSLに適さないプログラミング言語には、Java、C#、C++などがあります。Javaでは、XMLを使ってアプリケーションの振る舞いを変更するケースが非常に多くあります。この様な方法を一般的に外部DSLと呼んでます。

Rubyによる、CSVファイル読み込みDSL

第1回 - DSLとは?の中でCSVファイルを扱う例をあげ、一般的なプログラムとDSLを使ったメタプログラミングの違いを説明しました。今回は、実際にソースコードを示し説明します。そうすることで、一般的なプログラムと、内部DSLの違いと内部DSLを使う事のメリットを理解することができるからです。

CSVファイルの仕様は、1行目がカラム名、2行目以降がデータであり、整数型のID列を必須とします。また、RFC4180に準拠していることとします。

一般的なプログラム

一般的なプログラムの方法は、2つあります。クラスのメンバ変数に値を保持する方法と、ハッシュ(連想配列)に値を保持する方法です。

その2つの方法で共通する処理を抽出し、⁠CSVRecordクラス⁠を作ります。

表1 共通する処理
1CSVファイルのパスの指定
2CSVファイルの読み込み
3CSVデータの解析
4指定したデータの取得
  • a.先頭データ取得
  • b.最終データ取得
  • c.全データ取得
  • d.ID指定データ取得
  • e.複数ID指定データ取得

(1)メンバ変数に値を保持する方法

CSVRecordクラスを継承し、メールアドレスが記述されているCSVファイルを専用に扱います。クラス名は、Contactクラスとします。

Contactクラスには、CSVファイルの列をどのメンバ変数に割り当てるかを定義しなければなりません。⁠CSVファイルの1行目にカラム名が書いてありますが、列番号で管理した方がプログラムとして簡単であるため、この方法では、1行目のカラム名は使ってません⁠⁠。

クライアントがContactクラスにアクセスし、データを自然な形で取得できるように、アクセッサメソッドをそれぞれ定義します。

表2 CSVファイルの列とContactクラスのメンバ変数との対応表
列番号定数アクセッサメソッドの定義
1COL_ID = 0:id
2COL_FIRST_NAME = 1:first_name
3COL_LAST_NAME = 2:last_name
4COL_EMAIL = 3:email
class Contact 

この方法を採用することで生じる問題点は、共通的な処理を再利用可能なカタチにしたとしても限界があるということです。このプログラムは、メールアドレスCSV専用になっているため、他の形式のCSVファイルを扱う事ができません。そして、データフォーマットの変更(列の増減、列の属性の変更)に弱く、変更がある度にプログラムの修正をしなければなりません。

(2)ハッシュ(連想配列)に値を保持する方法

メンバ変数に値を保持する方法と同様に、CSVRecordクラスを継承し、CSVファイルのデータをハッシュに保持するクラスを定義します。2行目以降のデータ取得時に、1行目で取得した列情報をハッシュのキーと、取得した値をセットにしてデータを保持します。

class CSVHash < CSV::Base::CSVRecord
  def set_values(row_num, values)
    # CSVファイルの1行目の列名は、ハッシュのキーとして利用する.
    if (row_num == 1)
      @columns = values

    else
      hash = {}

      values.length.times do |i|
        # ハッシュに値を設定
        hash[@columns[i]] = values[i]
      end
      @records << hash
    end
  end
end

この方法を採用する事で生じる問題点は、ハッシュを使っていることから、キーを文字列(または、Rubyのシンボル型)としなければならないため、データを自然な形で扱えない、というデメリットがあります。

内部DSL

一般的なプログラムで実現できなかった、再利用が可能で、データを自然な形で扱うプログラムを考えてみます。

表1の内容を変更し、あらゆるフォーマットのCSVファイルをハンドリングできるようにします。

1つ目の変更箇所は、CSVファイルパスの指定のデフォルトをクラス名から自動的に取得するように変更します(クラス名 + ".csv"⁠⁠。

2つ目は、アクセッサメソッドをCSVファイルの1行目に記述されている列名から動的に生成します。このことで、自然な形でデータにアクセスすることができます。

表3 共通する処理
1クラス名から自動的にCSVファイル名を取得する
2CSVファイルの読み込み
3CSVデータの解析
4指定したデータの取得
  • a.先頭データ取得
  • b.最終データ取得
  • c.全データ取得
  • d.ID指定データ取得
  • e.複数ID指定データ取得
5アクセッサメソッドの自動生成

メールアドレスCSVファイルをハンドリングするクラスは、共通する処理以外に実装する機能(処理)がなければ、次の2行で実装は完了です。

class Contact < CSV::Base::CSVRecord
end

次の2つの要件を満たす内部DSLを実装していきます。

要件
  1. クラス名から、自動的にファイル名を取得する
    クラスのコンストラクタ(initializeメソッド)でRubyのリフレクションの機能を利用して、クラス名を取得し、パス名を生成します。
  2. アクセッサメソッドを自動生成する
    class_evalメソッドを使い対象となるクラスにアクセッサメソッドを動的に追加します。これにより、どのようなCSVファイルであろうとも1行目に記述されている列名からアクセッサメソッドを作ることができます。さらに、自然な形でデータにアクセスすることができます。
    行データは、配列として取得します。CSVのカラムとアクセッサメソッドは、マッピングされているので、値を自動的に設定することができます(set_valuesメソッド⁠⁠。
# CSVRecordクラス - 一部抜粋
class CSVRecord
  CSV_SUFFIX = ".csv"
  COLUMN_ROW_NUM = 1
  COL_ID = "id"

  def initialize()
    @columns = []
    @records = []
    
    # 1. クラス名からファイルを取得.
    @path = self.class.to_s.concat(CSV_SUFFIX)
    @klass = Class.new
    self.load
  end

protected
  def generate_accessors(columns)
    # 2. カラム名からアクセッサーメソッドを動的に生成.
    @columns = columns
    @columns.each do |col|
      @klass.class_eval %{
        def #{col}
          @#{col}
        end

        def #{col}= (value)
          @#{col} = value
        end
      }
    end
  end

  # 3. CSVファイルから取得したデータを保持する
  def set_values(values)
    object = @klass.new

    values.length.times do |i|
      if @columns[i] == COL_ID
        object.instance_variable_set("@#{@columns[i]}", values[i].to_i)
      else
        object.instance_variable_set("@#{@columns[i]}", values[i])
      end
    end

    @records 
表4 追加されたアクセッサ・メソッド
gettersetter
idid=
last_namelast_name=
first_namefirst_name=
emailemail=

内部DSLで書いたプログラムで、2つの要件を満たすことができました。

Rubyでは、DSLの作成にeval、class_eval、そしてinstance_evalという便利な機能を使う事ができます。evalは、文字列をRubyのステートメントとして評価します。evalはRubyカーネルのメソッドであり、オブジェクト内部や単純なスクリプトの中でも使うことができます。これにより、非常に柔軟なプログラムを作ることができるのです。

しかしながら、evalメソッドを多用すると結果を予想することが難しくなる、という副作用があります。evalメソッドは、ここぞ、という箇所でのみの使用をおすすめします。

まとめ

今回は、Rubyは強力な内部DSLを作ることに適したプログラミング言語である事が理解できました。次回は、さらに一歩進み、文章を書くような流暢なプログラム(Fluent interface)を可能にするDSLを考えていきたいと思います。

今回紹介したサンプルプログラムは以下よりダウンロードできます.

おすすめ記事

記事・ニュース一覧