Silvia Broome: What do you do when you can't sleep? (眠れないときはどうしてるの?)
Tobin Keller: I stay awake. (起きてるよ。)
問題を表現したり解決するために Python を利用しない方が理に適っている場合もあります。ある問題分野 (problem domain) に特化して利用される言語のことを「ドメイン固有言語 (domain-specific language)」と呼んでいます。
Python のような言語はあらゆる問題解決に利用できるように作成されています。このような言語のことを「汎用言語 (general-purpose languages)」と呼んでいます。しかし稀に、ある1つの問題だけに特化した、そしてその問題に関しては非常にうまくこなすことが可能な言語を利用する方が理に適っている場面もあります。そのような言語ファミリーのことを「ドメイン固有言語 (domain-specific languages: DSLs)」と呼び、プログラマーではないその特定領域の専門家に重宝されています。
だからと言って DSLs が特別なもの、というわけではありません。開発者としてのキャリアの中であたながもし web サイトの構築に少しでもかかわったことがあるのなら、きっと CSS はご存じのはずです。HTML がブラウザ上でどのように描画されるか、を記述するこの CSS も DSLs の1つです:
body {
color: #00ff00;
}
この CSS コードの意味は、このスタイルシートが適用されているウェブページの body タグ内のテキストの色を緑にしてね、というものです。これを実現するためのブラウザ側のコードはこの単純なコートスニペットよりも遥かに複雑ですが、CSS 自体はほとんどの人がその概略を数時間でマスターできるほど単純なものです。また HTML も DSL です。HTML もブラウザによって解析され、かなり複雑なフォーマットが施されたドキュメントとして表示されます。
この記事を読まれている方の中には、ビヘイビア駆動開発 (振舞駆動開発: behavior-driven development) によるプログラム開発において Aloe などのツールを利用し、自然言語 (人間が日常的に話す言葉) でプロジェクトの要求仕様 (acceptance criteria) の定義を行った経験がある方もいるでしょう。興味がある方、少しのぞいてみますか?
Aloe フレームワークをインストールします
(Python3.10 では 2022/6/18 現在 Aloe の実行時に AttributeError: module 'collections' has no attribute 'Callable' エラーが出ます。 Python3.9 以前で試してください):
pip install aloe
Feature: Compute factorial
In order to play with aloe
As beginners
We'll implement factorial
Scenario: Factorial of 0
Given I have the number 0
When I compute its factorial
Then I see the number 1
続いて、Aloe フレームワークが各ステップの「振る舞い」を理解するためのコードを記述します。このファイルは feature ファイルと同じフォルダ内に作成した features フォルダ内に配置します。またインポート可能である必要があるため、この features フォルダには __init__.py ファイルも作成しておく必要があります:
from aloe import before, step, world
@before.each_example
def clear(*args):
pass
@step(r'I have the number (\d+)')
def have_the_number(self, number):
world.number = int(number)
@step(r'I compute its factorial')
def check_number(self):
world.number = factorial(world.number)
@step(r'I see the number (\d+)')
def check_number(self, expected):
expected = int(expected)
assert world.number == expected, f'エラー: 期待値 {expected}, 計算値 {world.number}'
def factorial(number):
return -1
aloe aloe_first.feature の実行結果:
FAIL: Factorial of 0 (a_aloe_first: Compute factorial)
...
AssertionError: エラー: 期待値 1, 計算値 -1
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
このコードでは実際の factorial を計算せずただ単純に -1 を返していますので必ずエラーになります。1 を返すようにするとこのテストは成功し、次のような出力結果になります:
Ran 1 test in 0.000s
OK
Aloe などの BDD (Behavior Driven Development) 支援ツールを利用して記述したこのようなコードは、技術者以外の人が理解できる「言葉」でプログラムの実行順序・結果を記述し、それらの「言葉」を実際のコードにマップします。この .feature ファイルに記述された「言葉」は DSL であり、features/steps.py に記述した Python コードは、この DSL の内容をコンピュータが理解可能な形に変換するためのものです。
DSL を作成する、ということは、該当専門分野の専門家と開発者間の明確なコミュニケーション手段を提供する、ということです。専門家が説明しやすい環境が整うだけではなく、彼らが開発者、ならびに、コンピュータに求めているものが何であるのか、を開発者側もより深く理解することが可能となります。
専門家は現在の問題、または、ある問題に対する解決策を開発者に説明し、開発者はその説明を DSL として記述します。専門家にももちろんこの DSL の内容は「理解可能」なものですから、変更や追加すべき事項を提案することができます。このような過程を2,3日経れば充実した解決策を生み出すことができるでしょうし、関連するすべての人の生産性を大幅に向上させることが可能です。
あなたはレストランが「スペシャルサービス」を定義するためのシステムを開発する契約を結んでいます。このスペシャルサービスは、「1つ買ったら1つ無料」といったような単純なものの場合もあれば、「火曜日にピザ3枚お買い上げでその内の最も安いものは無料」といったようなより複雑なものかもしれません。そして、このシステムの課題は、これらのルールが頻繁に変更される、ということです。ですから、サービスが変更されるたびにコードを再度デプロイする、といったことは全く現実的ではありません。
もしあなたが、顧客がスペシャルサービスの内容を毎週変更する度にコードを変更し再度デプロイするような依頼を断れないのであれば、この契約を破棄する十分な理由になり得ますね。
しかし我々はちょうど DSL について学習しています。そこであなたは、この契約に適用可能な DSL を作成することが問題解決に繋がるのでは、と気付くわけです (気付きますよね?)。スペシャルサービスのルールに対する変更や追加はメインアプリケーションからは独立して管理され、必要な場合のみ取り出され利用されます。
以下は
非常に有益な講演 において ICE の技術主任 (Technical Lead) である Neil Green が提唱した「DSL 開発プロセス」です:
このステップを踏みながらレストランオーナーとの話を進めていってみましょう。まず、レストランオーナーがスペシャルサービスのルールについてどのように表現しているのかを学習し、我々双方が理解可能な言語に置き換えるようにします。続けてそれらをある種のダイアグラムとして描き出します(モデル化します)。最後に、我々が提示したものにオーナーの賛同が得られた場合、ダイアグラムを基にコードを記述します。
DSLs は2種類に大別することが可能です; 内部 DSL と外部 DSL です。外部 DSL は外部のファイル、もしくは、文字列として記述されたコードから成り立っています。そしてこれらのコードは実行前にアプリケーションによって取得、解析されます。CSS は外部 DSL で、HTML ページ表示時にブラウザによって読み込まれ、解析・解釈され、どのように HTML 要素をレイアウト・表示するのかを決定する「文字列データ」を構成しています。一方内部 DSL は、Python 等の言語の機能を使用して、該当専門領域の表現方法に近い構文でコードを記述することを可能にするものです。例えば、数学者が線形代数の問題を記述する際にプログラミング言語である Python に拠るのではなく、より従来の数式に近い形で記述できることを可能にする Numpy ライブラリなどはこの内部 DSL に当たります。
そしてこの章で取り上げるインタープリタパターン (interpreter pattern) は内部 DSL を実現するために採用されるものです。汎用言語のように全ての範囲を網羅するものではなく、しかし対象とする特定分野に関する限りより表現力のある構文を作成することが狙いです。
我々が開発を請け負っている「想像上のレストラン」におけるスペシャルサービスルールを Python で記述したものが次のコードです:
pizzas = [item for item in tab.items if item.type == 'pizza']
if len(pizzas) > 2 and day_of_week == 4:
cheapest_pizza_price = min([pizza.price for pizza in pizzas])
tab.add_dicsount(cheapest_pizza_price)
if 17 < hour_now < 19:
for item in tab.items:
if item.type == 'drink':
item.price = item.price * 0.90
if tab.customer.is_member():
item.price = item.price * 0.85
次に同じルールを単純な DSL で記述した例を見てその差を感じてください:
If tab contains 2 pizzas on Wednesdays cheapest one is free.
(水曜日に2つ以上のピザの購入で、安い方は無料)
Every day from 17:00 to 19:00 drinks are less 10%
All items are less 15% for members
(毎日17時から19時の間はドリンク全品10% OFF)
(メンバーは全ての商品15% OFF)
この段階ではコードとしての表現 (Aloe の例では各 steps の実装) は実行していません。しかし DSL による「スペシャルサービス」の定義が如何に「話し合い」の材料として向いているか、は明白です。これであれば「開発者」ではないビジネスオーナーにも、我々「開発者」の「スペシャルサービス」に対する理解が間違っていないか、もし間違えがあるとすれば何処なのか、という議論に加わってもらうことができます。双方の行き違いは最小限に抑えられ、曖昧さは消え去ります。新たなルールやスペシャルサービスの追加も非常に容易になります。双方にとって良いこと尽くめです。
DSL の利用によって、該当分野に関する理解とコミュニケーションレベルの顕著な向上が期待できます。その分野の専門家は、たとえソフトウェアエンジニアリングに対する詳細な知識を持ち合わせていなくとも、DSL で記述された問題や解決策について理解し意見を交わすことができるでしょう。結果的に、情報システムの構築をソフトウェア開発者からドメイン専門家に移行することが可能となり、その分野で使用されている用語で表現されたより充実し正確なシステムを構築できるようになります。
全ての受け持ち分野に対してそれぞれの DSLs を構築しよう、と試みる前に考慮しておかなければいけないことは、それぞれの分野の専門家にとっては「当たり前」のことであっても、門外漢である我々開発者にとっては DSLs の作成にはそれなりのコストがかかる、ということです。また、ある DSL が何を「語っている」のかを最初から理解できる人もいるかもしれませんが、それでもなお、その仕様に沿った実装を行うためにはそれなりの学習が必要となるはずです。
また、あなたが作成した DSL がより充実しより専門的になればなるほど、その分野の専門家にとっても「DSL の操作」に必要な知識は増加していくでしょう。その結果、それが制約要因となってシステムの使用、保守ができる人数を限定してしまうかもしれません。
常に念頭に置いておかなければいけないことは、ビジネスを支援するために DSL を導入するのであって、決して妨害するためではない、ということです。それでなければ、折角コストをかけて導入した DSL も長い間には破棄される憂き目を見ることになるかもしれません。
さぁ、ここまでの DSL に対する賛否をしっかりと頭に置いたうえで、我々の「想像上のレストラン」におけるスペシャルサービスルールを考えていきましょう。
マクロレベルでは、為すべきことが2つあります。1つ目は「言語の定義」です。もう少し詳しく言えば、言語の意味論 (semantics) 的側面と構造 (syntax) 的側面を定義することです。2つ目は「コードの記述」です。このコードは、1つ目で定義した言語を受け取り、コンピュータが理解し実行することができる形式に変換するためのものです。
顧客との会話に基づいて、プロセスに関係するものと、関係する全てのエンティティによって実行されるアクションを抜き出します。続けて、検討中のドメインにおいて特別な意味合いを帯びている単語やフレーズをピックアップします。これら3つの要素から文法を作成しますが、DSL として実装を行う前に対象分野の専門家と話し合いを行います。
ここで我々のレストランプロジェクトにおいて DSL 構築プロセスの例を考える前に、ご自分がツール作成者である、という意識を持っていただきたいと思います。ストレスや問題に直面した際、それらを解決する方法と、そのプロセスを10倍容易に速く処理するためのツールの作成を考える習慣をつけなければいけません。こういった意識が、他とは一線を画する最高のソフトウェア開発者、ソフトウェアエンジニアになるための条件です。
さて我々のレストランに戻りましょう。まず開発のスタートとしてレストランオーナー (ドメイン専門家) との話し合いの場を持ち、スペシャルサービスのルールを定義します:
メンバーは常に合計金額の 15% OFF
平日 17 時から 19 時のサービスタイムは全てのドリンクが 10% OFF
月曜日はハンバーガー2つ以上購入で一番安いもの1つ無料
木曜日はリブ食べ放題
日曜日の 18 時以降はピザ3枚購入でその内の最も安いものが無料
このサービスはピザ3枚ごとに適用。つまり6枚購入でその内の安いもの2枚無料など
members (会員)
tabs (伝票、勘定、合計)
happy hour (サービスタイム)
drinks (ドリンク)
weekdays (平日)
Mondays (月曜日)
burgers (ハンバーガー)
Thursday (木曜日)
ribs (リブ)
Sunday (日曜日)
pizzas (ピザ)
「関係する全てのエンティティによって実行されるアクション」をリストアップします:
get 15% discount (15% OFF)
get 10% discount (10% OFF)
get one free (1つ無料)
eat all you can (食べ放題)
これらからこのレストランにおけるスペシャルサービスのルールを導き出します:
もしある客があるタイプに属していれば、常に合計金額の X % (X は固定) を割り引く
ある時間帯に限り、あるタイプの全商品は X % (X は固定) 割引き
ある時間帯に限り、あるタイプの商品を1つ購入すると同タイプの2つ目は無料
さらに、スペシャルサービスに関するルールの主要点を一般化すると次のようになるでしょう:
ある条件を満たせばある特定の商品は X % 割引き
文法を形式的に表現する方法の1つとして Extended Backus-Naur Form (EBNF: 拡張バッカス・ナウア記法) があります。我々のレストラン例を EBNF で表現してみましょう:
rule: "If ", conditions, " then ", item_type, " get ", discount
conditions: condition | conditions " and " conditions | conditions " or " conditions
condition: time_condition | item_condition | customer_condition
discount: number, "% discount" | "cheapest ", number, item_type " free"
time_condition: "today is ", day_of_week | "time is between ", time, " and ", time | "today is a week day" | "today not a week day"
day_of_week: "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday"
time: hours, ":", minutes
hours: hour_tens, hour_ones
hour_tens: "0" | "1"
hour_ones: digit
minutes: minute_tens, minute_ones
minute_tens: "0" | "1" | "2" | "3" | "4" | "5"
minute_ones: digit
item_condition: "item is a ", item_type | "there are ", number, " of ", item_type
item_type: "pizza" | "burger" | "drink" | "chips"
number: {digit}
digit: "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
customer_conditions: "customer is a ", customer_type
customer_type: "member" | "non-member"
終端記号 (terminal symbols: 右辺にのみ現れ左辺には現れない) は他のパターンと置き換えはできません。非終端記号 (no-terminal symbols) はいずれかの規則の左辺に現れる構文ルールとして宣言されているもので、これらは続く終端文字や非終端文字の組み合わせによって置き換えられるものです。また、複数の置き換え方法を定義する場合はそれぞれを "|" で区切ります。
このような外部 DSL を作成する場合、PyParsing などのパッケージを利用してこの例のように記述した文字列を Python コード内で利用できるデータ型に変換することが可能です。
さて、ここまでがあるドメインをモデル化する一連の流れ (プロセス) になります。まず、ドメイン専門家との話し合いを通して対象ドメインに対する理解を深め、主要な要素をリストアップし、ここまでの理解をドメイン専門家も理解・検証可能な形で体系化します。そしてこのプロセスの最終生成物として DSL を実装するわけです。
ではここから内部 DSL (internal DSL) の実装に取り組んでいきましょう。
ある特別なルールを内部 DSL として実装する場合、そのドメインにおける主要な要素1つ1つそれぞれを個別のクラスとして定義するのが一般的です。
手始めに、先の EBNF による定義に沿って骨組み (stubs) を作成します:
class Tab:
pass
class Item:
pass
class Customer:
pass
class Discount:
pass
class Rule:
pass
class CustomerType:
pass
class ItemType:
pass
class Conditions:
pass
class Condition:
pass
class TimeCondition:
pass
class DayOfWeek:
pass
class Time:
pass
class Hours:
pass
class HourTens:
pass
class HourOnes:
pass
class Minutes:
pass
class MinuteTens:
pass
class MinuteOnes:
pass
class ItemCondition:
pass
class Number:
pass
class Digit:
pass
class CustomerCondition:
pass
最終的にこれらすべてのクラスが必要になるわけではないと思いますし、常に YAGNI (You Ain't Gonna Need It: それは多分必要じゃないよね) 原則を念頭に置いておかなければいけません。しかしここでは、文法定義のプロセスの一般論として主要な構成要素1つ1つをクラスに割り当てる、ことをお見せするために全てを書き出してみたまでです。
不要なクラスを消去し最終的な内部 DSL を作成する前に、インタプリタの作成において助けとなるコンポジットパターン (composite pattern) ついて触れておきたいと思います。
それ自体がコンテナである可能性のある要素を含むコンテナがある場合コマンドパターンを適用できます。レストランスペシャルサービスルールのために作成した我々の文法の EBNF バージョンにおいては item は他の items を含んでいる可能性があります。
コンポジットパターンの構成要素は Composite (枝: 非終端要素) と Leaf (葉: 終端要素) です。これらが再帰的な木構造を形成します:
class Leaf:
def __init__(self, *args, **kwargs):
pass
def component_function(self):
print('Leaf')
class Composite:
def __init__(self, *args, **kwargs):
self.children = []
def component_function(self):
for child in children:
child.component_function()
def add(self, child):
self.children.append(child)
def remove(self, child):
self.children.remove(child)
動的ではない言語では、Composite クラスと Leaf クラス双方が継承する抽象インターフェースクラスを定義する必要がありますが、ダックタイピング言語である Python では必要ありません。
対象レストランの最初のスペシャルサービスルール「メンバーは常に合計金額の 15% OFF」について、基本的な内部 DSL としての実装を考えてみましょう:
from math import floor
class CustomerType:
def __init__(self, customer_type: str):
self.customer_type = customer_type
class Customer:
def __init__(self, customer_type: CustomerType, name: str):
self.customer_type = customer_type
self.name = name
def is_a(self, customer_type: CustomerType) -> bool:
return self.customer_type == customer_type
class ItemType:
def __init__(self, name: str):
self.name = name
class Item:
def __init__(self, name: str, item_type: ItemType, cost: int):
self.name = name
self.item_type = item_type
self.cost = cost
class Tab:
def __init__(self, customer: Customer):
self.items: list[Item] = []
self.discounts: list[int] = []
self.customer = customer
def calculate_cost(self) -> int:
return sum(x.cost for x in self.items)
def calculate_discount(self) -> int:
return sum(x for x in self.discounts)
class Discount:
def __init__(self, amount: int):
self.amount = amount
class Rule:
def __init__(self, tab: Tab):
self.tab = tab
self.conditions: list[bool] = []
self.discounts: list[Discount] = []
def add_condition(self, test_value: bool):
self.conditions.append(test_value)
def add_percentage_discount(self, item_type: str, percent: int):
if item_type == 'any item':
f = lambda x: True
else:
f = lambda x: x.item_type == item_type
items_to_discount = [item for item in self.tab.items if f(item)]
for item in items_to_discount:
discount = Discount(floor(item.cost * (percent / 100.0)))
self.discounts.append(discount)
def apply(self) -> int:
if all(self.conditions):
return sum(x.amount for x in self.discounts)
return 0
if __name__ == '__main__':
member = CustomerType('Member')
non_member = CustomerType('Non-membger')
member_customer = Customer(member, 'John')
tab = Tab(member_customer)
pizza = ItemType('Pizza')
burger = ItemType('Burger')
drink = ItemType('Drink')
tab.items.append(Item('ペスカトーレ', pizza, 1600))
tab.items.append(Item('フィレオフィッシュ', burger, 350))
tab.items.append(Item('マンゴージュース', drink, 650))
rule = Rule(tab)
rule.add_condition(tab.customer.is_a(member))
rule.add_percentage_discount('any item', 15)
tab.discounts.append(rule.apply())
print(
f'合計金額: {tab.calculate_cost()}円\n'
f'割引金額: {tab.calculate_discount()}円\n'
f'支払金額: {tab.calculate_cost() - tab.calculate_discount()}円\n'
f'割引率: {round(100 * tab.calculate_discount() / tab.calculate_cost(), 2)}%'
)
ある1つのスペシャルサービスルールを、ルールを構成する各要素を表現するクラスを利用して機能させることに成功しましたので、コンポジットパターンを使用した DSL の実装へ戻りましょう。ルール条件 (conditions) は:
単一のブール式 (Boolean expression: EBNF で表現した 'condition')
複数の条件式の結合 (a set of conjuncted conditions: EBNF で表現した 'conditions " and " conditions')
複数の条件式のいずれか (a set of disjuncted conditions: EBNF で表現した 'conditions " or " conditions')
from collections.abc import Callable
class Item:
def __init__(self, name: str):
self.name = name
class Tab:
def __init__(self):
self.items: list[Item] = []
class CompositeConditionBase:
def evaluate(self, tab: Tab) -> bool:
pass
def add(self, condition: CompositeConditionBase):
pass
def remove(self, condition: CompositeConditionBase):
pass
class Condition(CompositeConditionBase):
def __init__(self, condition_function: Callable[[Tab], bool]):
self.test = condition_function
def evaluate(self, tab: Tab) -> bool:
return self.test(tab)
class AndConditions(CompositeConditionBase):
def __init__(self):
self.conditions: list[CompositeConditionBase] = []
def evaluate(self, tab: Tab) -> bool:
return all(x.evaluate(tab) for x in self.conditions)
def add(self, condition: CompositeConditionBase):
self.conditions.append(condition)
def remove(self, condition: CompositeConditionBase):
self.conditions.remove(condition)
class OrConditions(CompositeConditionBase):
def __init__(self):
self.conditions: list[CompositeConditionBase] = []
def evalueate(self, tab: Tab) -> bool:
return any(x.evaluate(tab) for x in self.conditions)
def add(self, condition: CompositeConditionBase):
self.conditions.append(condition)
def remove(self, condition: CompositeConditionBase):
self.conditions.remove(condition)
class Discount:
def __init__(self, test_function: Callable[[Item], bool], discount_function: Callable[[Item], int]):
self.test = test_function
self.discount = discount_function
def calculate(self, tab: Tab) -> int:
return sum(self.discount(item) for item in tab.items if self.test(item))
class Discounts:
def __init__(self):
self.children: list[Discount] = []
def calculate(self, tab: Tab) -> int:
return sum(x.calculate(tab) for x in self.children)
def add(self, child: Discount):
self.children.append(child)
def remove(self, child: Discount):
self.children.remove(child)
class Rule:
def __init__(self, tab: Tab):
self.tab = tab
self.conditions = AndConditions()
self.discounts = Discounts()
def add_conditions(self, conditions: CompositeConditionBase):
self.conditions.add(conditions)
def add_discount(self, test_function: Callable[[Item], bool], discount_function: Callable[[Item], int]):
discount = Discount(test_function, discount_function)
self.discounts.add(discount)
def apply(self) -> int:
if self.conditions.evaluate(self.tab):
return self.discounts.calculate(self.tab)
return 0