| Topic 14 | ภาษาเฉพาะโดเมน |
|---|
ขีดจำกัดของภาษา คือขีดจำกัดของโลกของแต่ละคน
Ludwig Wittgenstein
ภาษา Programming มีอิทธิพลต่อ วิธี ที่คุณคิดถึงปัญหา และวิธีที่คุณคิดถึงการสื่อสาร ทุกภาษามาพร้อมกับ Feature ชุดหนึ่ง ไม่ว่าจะเป็น Buzzword อย่าง Static กับ Dynamic Typing, Early กับ Late Binding, Functional กับ OO, Inheritance Model, Mixin, Macro—ทั้งหมดนี้อาจชี้นำหรือบดบัง Solution บางแบบได้ การออกแบบ Solution โดยคิดแบบ C++ ย่อมได้ผลลัพธ์ต่างจาก Solution ที่คิดแบบ Haskell และก็เป็นจริงในทางกลับกันด้วย แต่สิ่งที่ตรงข้ามกัน และเราคิดว่าสำคัญกว่าด้วยซ้ำ ก็คือภาษาของ Problem Domain เองก็อาจชี้นำ Solution ในการเขียนโปรแกรมได้เหมือนกัน
เรามักจะพยายามเขียน Code โดยใช้คำศัพท์ของ Application Domain อยู่เสมอ (ดู Maintain a Glossary ) ในบางกรณี Pragmatic Programmer สามารถก้าวไปอีกขั้น ด้วยการเขียนโปรแกรมโดยใช้คำศัพท์ ไวยากรณ์ และความหมาย—นั่นคือตัวภาษาเอง—ของ Domain นั้นๆ ได้เลยครับ
| Tip 22 | เขียนโปรแกรมให้ใกล้เคียงกับ Problem Domain มากที่สุด |
|---|
Some Real-World Domain Languages (ตัวอย่าง Domain Language ในโลกจริง)
ลองมาดูตัวอย่างสักสองสามอย่างที่มีคนทำแบบนั้นมาแล้วกันครับ
RSpec (Testing Library ของ Ruby)
RSpec [19] คือ Testing Library สำหรับ Ruby ที่เป็นแรงบันดาลใจให้เกิด Version สำหรับภาษาสมัยใหม่อื่นๆ อีกมากมาย Test ใน RSpec ถูกออกแบบมาให้สะท้อนถึง Behavior ที่คุณคาดหวังจาก Code ของคุณ
| | describe BowlingScore do |
|---|---|
| | it "totals 12 if you score 3 four times" do |
| | score = BowlingScore.new |
| | 4.times { score.add_pins(3) } |
| | expect(score.total).to eq(12) |
| | end |
| | end |
Cucumber (เครื่องมือทดสอบข้ามภาษา)
Cucumber [20] เป็นวิธีเขียน Test ที่ไม่ผูกติดกับภาษา Programming ใดภาษาหนึ่ง คุณรัน Test โดยใช้ Cucumber Version ที่เหมาะกับภาษาที่คุณใช้งานอยู่ เพื่อรองรับ Syntax ที่คล้ายภาษาธรรมชาติแบบนี้ คุณยังต้องเขียน Matcher เฉพาะที่คอยจับวลีต่างๆ และดึงค่า Parameter ออกมาสำหรับ Test ด้วย
| | Feature : Scoring |
|---|---|
| | |
| | Background : |
| | Given an empty scorecard |
| | |
| | Scenario : bowling a lot of 3s |
| | Given I throw a 3 |
| | And I throw a 3 |
| | And I throw a 3 |
| | And I throw a 3 |
| | Then the score should be 12 |
Test ของ Cucumber ถูกออกแบบมาให้ลูกค้าของ Software อ่านได้ (แม้ว่าในทางปฏิบัติแล้วแทบจะไม่ค่อยเกิดขึ้นจริงก็ตาม ส่วนถัดไปจะพูดถึงว่าทำไมถึงเป็นแบบนั้น)
ทำไม Business User หลายคนถึงไม่อ่าน Cucumber Feature?
เหตุผลหนึ่งที่แนวทางคลาสสิกแบบ เก็บ Requirement ออกแบบ เขียน Code แล้ว Ship ใช้ไม่ได้ผล ก็เพราะมันตั้งอยู่บนแนวคิดที่ว่าเรารู้ว่า Requirement คืออะไร แต่ในความเป็นจริงเราแทบไม่เคยรู้เลย Business User ของคุณมักจะมีไอเดียคร่าวๆ ว่าอยากได้อะไร แต่พวกเขาไม่รู้และไม่สนใจรายละเอียดด้วยซ้ำ นั่นแหละคือคุณค่าส่วนหนึ่งของเรา: เราคอยจับ Intent แล้วแปลงมันออกมาเป็น Code
ดังนั้นเวลาคุณบังคับให้ Business Person เซ็นอนุมัติเอกสาร Requirement หรือให้เขาเห็นด้วยกับชุด Cucumber Feature มันก็ไม่ต่างจากให้เขาไปตรวจตัวสะกดของเรียงความที่เขียนเป็นภาษาสุเมเรียน พวกเขาก็จะแก้อะไรมั่วๆ ไปสักหน่อยเพื่อรักษาหน้า แล้วก็เซ็นผ่านให้คุณออกจากห้องไปซะที
แต่ถ้าคุณให้ Code ที่รันได้จริงกับพวกเขา พวกเขาจะลองเล่นดูได้ และตรงนั้นแหละที่ความต้องการที่แท้จริงของพวกเขาจะปรากฏออกมาครับ
Phoenix Routes (Route ของ Phoenix)
Web Framework หลายตัวมี Routing Facility ที่คอย Map HTTP Request ที่เข้ามาไปยัง Handler Function ใน Code นี่คือตัวอย่างจาก Phoenix [21]
| | scope " /" , HelloPhoenix do |
|---|---|
| | pipe_through :browser # Use the default browser stack |
| | |
| | get " /" , PageController, :index |
| | resources " /users" , UserController |
| | end |
โค้ดนี้บอกว่า Request ที่ขึ้นต้นด้วย “/” จะถูกรันผ่านชุด Filter ที่เหมาะกับ Browser ส่วน Request ไปที่ “/” เอง จะถูกจัดการโดย Function index ใน Module PageController ส่วน UsersController จะ Implement Function ที่จำเป็นสำหรับจัดการ Resource ที่เข้าถึงได้ผ่าน URL /users .
Ansible (เครื่องมือ Configuration)
Ansible [22] เป็น Tool สำหรับ Configure Software มักใช้กับ Remote Server จำนวนมาก มันทำงานโดยอ่าน Specification ที่คุณเตรียมไว้ แล้วจัดการทุกอย่างที่จำเป็นบน Server เพื่อให้ตรงกับ Spec นั้น Specification เขียนด้วย YAML [23] ได้ ซึ่งเป็นภาษาที่สร้าง Data Structure จากคำอธิบายแบบข้อความ:
| | --- |
|---|---|
| | - name: install nginx |
| | apt: name=nginx state=latest |
| | |
| | - name: ensure nginx is running (and enable it at boot) |
| | service: name=nginx state=started enabled=yes |
| | |
| | - name: write the nginx config file |
| | template: src=templates/nginx.conf.j2 dest=/etc/nginx/nginx.conf |
| | notify: |
| | - restart nginx |
ตัวอย่างนี้ทำให้แน่ใจว่า nginx Version ล่าสุดถูกติดตั้งบน Server ของผม มันถูก Start โดย Default และมันใช้ Configuration File ที่คุณเตรียมไว้ให้
Characteristics of Domain Languages (คุณลักษณะของ Domain Language)
ลองมาดูตัวอย่างเหล่านี้ให้ละเอียดขึ้นกันครับ
RSpec และ Phoenix Router เขียนด้วย Host Language ของมันเอง (Ruby และ Elixir) มันใช้ Code ที่ค่อนข้างซับซ้อนพิสดารอยู่บ้าง อย่าง Metaprogramming และ Macro แต่สุดท้ายแล้วก็ถูก Compile และรันเป็น Code ปกติทั่วไป
ส่วน Test ของ Cucumber และ Configuration ของ Ansible เขียนด้วยภาษาของตัวเอง Test ของ Cucumber จะถูกแปลงเป็น Code ที่รันได้ หรือแปลงเป็น Data Structure ในขณะที่ Spec ของ Ansible จะถูกแปลงเป็น Data Structure เสมอ แล้วให้ Ansible เองเป็นตัวรัน
ผลก็คือ Code ของ RSpec และ Router ถูกฝังอยู่ใน Code ที่คุณรันจริงๆ มันคือส่วนขยายที่แท้จริงของคำศัพท์ใน Code ของคุณ ส่วน Cucumber กับ Ansible นั้นถูก อ่าน โดย Code แล้วแปลงเป็นรูปแบบบางอย่างที่ Code นำไปใช้ต่อได้
เราเรียก RSpec และ Router ว่าเป็นตัวอย่างของ Internal Domain Language ส่วน Cucumber กับ Ansible ใช้ External Language
Trade-Offs Between Internal and External Languages (ข้อแลกเปลี่ยนระหว่าง Internal กับ External Language)
โดยทั่วไปแล้ว Internal Domain Language ได้เปรียบตรงที่ใช้ประโยชน์จาก Feature ของ Host Language ได้เลย ทำให้ Domain Language ที่คุณสร้างขึ้นมีพลังมากขึ้น และได้พลังนั้นมาแบบฟรีๆ ด้วย ตัวอย่างเช่น คุณอาจใช้ Code Ruby บางส่วนสร้าง RSpec Test จำนวนมากขึ้นมาอัตโนมัติ ในกรณีนี้เราสามารถ Test คะแนนที่ไม่มี Spare หรือ Strike ได้เลย:
| | describe BowlingScore do |
|---|---|
| | (0..4).each do |pins| |
| | (1..20).each do |throws| |
| | target = pins * throws |
| | |
| | it "totals #{ target } if you score #{ pins } #{ throws } times" do |
| | score = BowlingScore.new |
| | throws.times { score.add_pins(pins) } |
| | expect(score.total).to eq(target) |
| | end |
| | end |
| | end |
| | end |
นั่นคือ Test 100 ตัวที่คุณเพิ่งเขียนเสร็จ หยุดพักที่เหลือของวันนี้ได้เลยครับ
ข้อเสียของ Internal Domain Language ก็คือคุณถูกผูกไว้กับ Syntax และ Semantics ของภาษานั้นๆ แม้บางภาษาจะยืดหยุ่นในเรื่องนี้มากเป็นพิเศษ แต่คุณก็ยังต้องประนีประนอมระหว่างภาษาที่คุณอยากได้กับภาษาที่คุณ Implement ได้จริงอยู่ดี
สุดท้ายแล้ว ไม่ว่าคุณจะคิดอะไรขึ้นมา มันก็ต้องยังเป็น Syntax ที่ถูกต้องในภาษาเป้าหมายของคุณอยู่ดี ภาษาที่มี Macro (อย่าง Elixir, Clojure และ Crystal) จะให้ความยืดหยุ่นเพิ่มขึ้นมาอีกนิดหน่อย แต่สุดท้าย Syntax ก็คือ Syntax
External Language ไม่มีข้อจำกัดแบบนั้น ตราบใดที่คุณเขียน Parser สำหรับภาษานั้นได้ คุณก็ไปต่อได้เลย บางครั้งคุณอาจใช้ Parser ของคนอื่น (แบบที่ Ansible ใช้ YAML) แต่นั่นก็ทำให้คุณต้องกลับไปประนีประนอมอีกเหมือนกัน
การเขียน Parser มักหมายถึงการเพิ่ม Library ใหม่ๆ และอาจต้องเพิ่ม Tool เข้าไปใน Application ของคุณด้วย และการเขียน Parser ดีๆ สักตัวก็ไม่ใช่งานง่ายๆ เลย แต่ถ้าคุณกล้าพอ ลองมองหา Parser Generator อย่าง bison หรือ ANTLR รวมถึง Parsing Framework อย่าง PEG Parser ที่มีให้เลือกใช้เยอะแยะดูก็ได้ครับ
คำแนะนำของเราค่อนข้างง่ายเลยครับ: อย่าลงแรงมากกว่าที่คุณจะประหยัดได้ การเขียน Domain Language เพิ่ม Cost ให้กับโปรเจกต์ของคุณ ดังนั้นคุณต้องมั่นใจว่ามันคุ้มค่าพอที่จะชดเชยกัน (อาจจะในระยะยาว)
โดยทั่วไปแล้ว ใช้ External Language สำเร็จรูป (อย่าง YAML, JSON หรือ CSV) ถ้าเป็นไปได้ ถ้าไม่ได้ ก็ลองมองไปที่ Internal Language แทน เราแนะนำให้ใช้ External Language เฉพาะในกรณีที่ User ของ Application คุณจะเป็นคนเขียนภาษานั้นเอง
An Internal Domain Language on the Cheap (Internal Domain Language แบบประหยัด)
สุดท้ายนี้ มีทางลัดสำหรับสร้าง Internal Domain Language ถ้าคุณไม่ซีเรียสที่ Syntax ของ Host Language จะโผล่ออกมาให้เห็นบ้าง ไม่ต้องไปทำ Metaprogramming เยอะแยะ แค่เขียน Function ธรรมดาให้มันทำงานก็พอ อันที่จริงนี่ก็คือสิ่งที่ RSpec ทำอยู่แล้วประมาณนี้เลยครับ:
| | describe BowlingScore do |
|---|---|
| | it "totals 12 if you score 3 four times" do |
| | score = BowlingScore.new |
| | 4.times { score.add_pins(3) } |
| | expect(score.total).to eq(12) |
| | end |
| | end |
ใน Code นี้ describe , it , expect , to และ eq ก็แค่ Method ของ Ruby ธรรมดาๆ มี Plumbing เล็กน้อยเบื้องหลังเรื่องการส่ง Object ไปมา แต่ทั้งหมดก็คือ Code ธรรมดา เราจะลองสำรวจเรื่องนี้กันอีกนิดใน Exercise ครับ
Related Sections Include (ส่วนที่เกี่ยวข้อง)
- Topic 8, The Essence of Good Design
- Topic 13, Prototypes and Post-it Notes
- Topic 32, Configuration
Challenges (ความท้าทาย)
-
Requirement บางอย่างในโปรเจกต์ปัจจุบันของคุณ สามารถเขียนออกมาเป็น Domain-specific Language ได้ไหม? เป็นไปได้ไหมที่จะเขียน Compiler หรือ Translator ที่ Generate Code ส่วนใหญ่ที่ต้องการออกมาได้?
-
ถ้าคุณตัดสินใจใช้ Mini-language เป็นวิธีเขียนโปรแกรมให้ใกล้เคียงกับ Problem Domain มากขึ้น คุณก็ต้องยอมรับว่าต้องลงแรง Implement มันด้วย คุณเห็นวิธีไหนบ้างไหมที่ Framework ที่คุณพัฒนาสำหรับโปรเจกต์หนึ่ง จะเอาไปใช้ซ้ำกับโปรเจกต์อื่นได้?
Exercises (แบบฝึกหัด)
Exercise 4 ( คำตอบที่เป็นไปได้ )
เราต้องการ Implement Mini-language สำหรับควบคุมระบบ Turtle-graphics แบบง่ายๆ ภาษานี้ประกอบด้วยคำสั่งตัวอักษรเดียว บางคำสั่งตามด้วยตัวเลขหนึ่งตัว ตัวอย่างเช่น Input ต่อไปนี้จะวาดรูปสี่เหลี่ยมผืนผ้า:
| | P 2 # select pen 2 |
|---|---|
| | D # pen down |
| | W 2 # draw west 2cm |
| | N 1 # then north 1 |
| | E 2 # then east 2 |
| | S 1 # then back south |
| | U # pen up |
Implement Code ที่ Parse ภาษานี้ ออกแบบให้เพิ่มคำสั่งใหม่ได้ง่ายด้วยครับ
Exercise 5 ( คำตอบที่เป็นไปได้ )
ใน Exercise ก่อนหน้านี้ เรา Implement Parser สำหรับภาษาวาดรูป—ซึ่งเป็น External Domain Language ทีนี้ลอง Implement มันใหม่ ในรูปแบบ Internal Language ดูบ้าง ไม่ต้องทำอะไรซับซ้อน แค่เขียน Function สำหรับแต่ละคำสั่งก็พอ คุณอาจต้องเปลี่ยนชื่อคำสั่งให้เป็นตัวพิมพ์เล็ก และอาจต้อง Wrap มันไว้ในอะไรสักอย่างเพื่อให้มี Context ด้วย
Exercise 6 ( คำตอบที่เป็นไปได้ )
ออกแบบ BNF Grammar สำหรับ Parse ข้อกำหนดเวลา ตัวอย่างต่อไปนี้ทั้งหมดต้องถูกยอมรับได้:
| | 4pm, 7:38pm, 23:42, 3:16, 3:16am |
|---|
Exercise 7 ( คำตอบที่เป็นไปได้ )
Implement Parser สำหรับ BNF Grammar ใน Exercise ก่อนหน้า โดยใช้ PEG Parser Generator ในภาษาที่คุณถนัด Output ควรเป็นจำนวนเต็มที่บอกจำนวนนาทีที่ผ่านไปนับจากเที่ยงคืน
Exercise 8 ( คำตอบที่เป็นไปได้ )
Implement Time Parser โดยใช้ Scripting Language และ Regular Expression