Topic 23 Design by Contract

Nothing astonishes men so much as common sense and plain dealing.

Ralph Waldo Emerson, Essays

การจัดการกับระบบคอมพิวเตอร์นั้นยากแล้ว แต่การจัดการกับคนนั้นยากยิ่งกว่า ทว่าในฐานะสิ่งมีชีวิต เรามีเวลาทำความเข้าใจปัญหาเรื่องปฏิสัมพันธ์ของมนุษย์มายาวนานกว่ามาก วิธีแก้ปัญหาบางอย่างที่เราคิดค้นขึ้นมาในช่วงไม่กี่พันปีที่ผ่านมาสามารถนำมาปรับใช้กับการเขียนซอฟต์แวร์ได้เช่นกัน หนึ่งในวิธีที่ดีที่สุดเพื่อให้แน่ใจว่าการดีลเป็นไปอย่างตรงไปตรงมาก็คือ สัญญา (Contract)

Contract จะกำหนดสิทธิและความรับผิดชอบของคุณ รวมถึงของอีกฝ่ายหนึ่งด้วย นอกจากนี้ยังมีข้อตกลงเกี่ยวกับผลที่ตามมา (repercussions) หากฝ่ายใดฝ่ายหนึ่งไม่ปฏิบัติตาม Contract

คุณอาจจะมีสัญญาจ้างงาน (employment contract) ที่ระบุชั่วโมงการทำงานและกฎเกณฑ์ที่ต้องปฏิบัติตาม ในทางกลับกัน บริษัทก็จะจ่ายเงินเดือนและสิทธิประโยชน์อื่นๆ ให้ ทุกฝ่ายปฏิบัติตามภาระผูกพัน (obligations) ของตนเอง และทุกคนก็ได้ประโยชน์

มันเป็นแนวคิดที่ใช้กันทั่วโลก ทั้งแบบที่เป็นทางการและไม่เป็นทางการ เพื่อช่วยให้มนุษย์โต้ตอบกันได้ เราจะสามารถนำคอนเซปต์เดียวกันนี้มาใช้เพื่อช่วยให้โมดูลซอฟต์แวร์โต้ตอบกันได้หรือไม่? คำตอบคือ “ใช่”

DBC

Bertrand Meyer (_Object-Oriented Software Construction_ [Mey97]) ได้พัฒนาคอนเซปต์ของ Design by Contract สำหรับภาษา Eiffel.[30] มันเป็นเทคนิคที่เรียบง่ายแต่ทรงพลัง โดยมุ่งเน้นไปที่การจัดทำเอกสาร (และยอมรับ) ในสิทธิและความรับผิดชอบของซอฟต์แวร์โมดูลเพื่อให้แน่ใจถึงความถูกต้องของโปรแกรม (program correctness) แล้วโปรแกรมที่ถูกต้องคืออะไร? ก็คือโปรแกรมที่ทำงานไม่มากไปและไม่น้อยไปกว่าที่มันอ้างไว้นั่นเอง การบันทึกและตรวจสอบคำกล่าวอ้างนั้นคือหัวใจของ Design by Contract (เรียกสั้นๆ ว่า DBC)

ทุก function และ method ในระบบซอฟต์แวร์นั้นมีหน้าที่ทำบางสิ่งบางอย่าง ก่อนที่มันจะเริ่มทำสิ่งนั้น function อาจจะมีความคาดหวังบางอย่างเกี่ยวกับสภาวะของโลก (state of the world) และมันอาจจะสามารถประกาศถึงสภาวะหลังจากที่มันทำงานเสร็จสิ้นแล้วได้ด้วย Meyer อธิบายความคาดหวังและคำประกาศเหล่านี้ไว้ดังนี้:

Preconditions

สิ่งที่ต้องเป็นจริงก่อนที่จะมีการเรียก routine; ข้อกำหนดของ routine นั้นๆ routine ไม่ควรถูกเรียกเลยหาก precondition ถูกฝ่าฝืน การส่งข้อมูลที่ถูกต้องมาให้นั้นเป็นหน้าที่ของตัว caller (ดูที่กล่อง ตรงนี้)

Postconditions

สิ่งที่ routine การันตีว่าจะทำ; สภาวะหลังจากที่ routine ทำงานเสร็จสิ้น การที่ routine มี postcondition นั้นแปลว่ามันจะจบลงได้: ไม่อนุญาตให้มี infinite loops

Class invariants

class จะรับรองว่าเงื่อนไขนี้จะเป็นจริงเสมอเมื่อมองจากมุมของ caller ระหว่างการประมวลผลภายใน routine ค่า invariant นี้อาจจะไม่เป็นจริงก็ได้ แต่เมื่อถึงเวลาที่ routine จบการทำงานและคืนการควบคุมกลับไปที่ caller ค่า invariant จะต้องเป็นจริง (โปรดสังเกตว่า class ไม่สามารถให้สิทธิการเขียนแบบไม่มีข้อจำกัด (unrestricted write-access) ต่อ data member ใดๆ ที่เกี่ยวข้องกับ invariant ได้)

สัญญา (contract) ระหว่าง routine และ caller ที่น่าจะเรียกใช้งานสามารถอ่านได้ว่า

หาก preconditions ทั้งหมดของ routine นั้นถูกตอบสนองโดย caller แล้ว routine จะการันตีว่า postconditions และ invariants ทั้งหมดจะเป็นจริงเมื่อมันทำงานเสร็จสิ้น

หากฝ่ายใดฝ่ายหนึ่งไม่สามารถปฏิบัติตามข้อตกลงในสัญญาได้ มาตรการแก้ไข (ที่ตกลงกันไว้ก่อนแล้ว) ก็จะถูกนำมาใช้—เช่น อาจจะมีการโยน exception ขึ้นมา หรือโปรแกรมจบการทำงานไปเลย ไม่ว่าจะเกิดอะไรขึ้นก็ตาม โปรดระลึกไว้ว่าการไม่ทำตามสัญญานั้นคือ “บั๊ก” มันไม่ใช่สิ่งที่ควรจะเกิดขึ้นเลย นี่คือเหตุผลที่ว่าทำไมเราจึงไม่ควรใช้ preconditions ในการตรวจสอบความถูกต้องของอินพุตจากผู้ใช้ (user-input validation)

บางภาษามีการรองรับคอนเซปต์เหล่านี้ดีกว่าภาษาอื่นๆ ตัวอย่างเช่น Clojure นั้นรองรับทั้ง pre- และ post-conditions รวมถึงการตรวจสอบที่ครอบคลุมกว่าซึ่งมาพร้อมกับ specs นี่คือตัวอย่างของฟังก์ชันการธนาคารเพื่อทำรายการฝากเงินโดยใช้ pre- และ post-conditions แบบง่ายๆ:

(​defn​ accept-deposit [account-id amount]
{ ​:pre​ [ (> amount 0.00)
(account-open? account-id) ]
​:post​ [ (contains? (account-transactions account-id) %) ] }
​"Accept a deposit and return the new transaction id"​
​;; Some other processing goes here...​
​;; Return the newly created transaction:​
(create-transaction account-id :deposit amount))

มีสอง preconditions สำหรับฟังก์ชัน accept-deposit อย่างแรกคือจำนวนเงิน (amount) ต้องมากกว่าศูนย์ และอย่างที่สองคือบัญชีต้องเปิดอยู่และถูกต้อง (valid) ซึ่งกำหนดโดยฟังก์ชันที่ชื่อว่า account-open? นอกจากนี้ยังมี postcondition: โดยฟังก์ชันจะรับรองว่ารายการธุรกรรมใหม่ (ค่าที่ส่งคืนจากฟังก์ชันนี้ ซึ่งแทนด้วยเครื่องหมาย "%") จะสามารถหาพบในรายการธุรกรรมทั้งหมดของบัญชีนี้ได้

หากคุณเรียกใช้ accept-deposit ด้วยจำนวนเงินที่เป็นบวกสำหรับการฝากและใช้บัญชีที่ถูกต้อง มันก็จะดำเนินขั้นตอนการสร้างธุรกรรมในประเภทที่เหมาะสมและทำงานอื่นๆ ต่อไป อย่างไรก็ตาม หากมีบั๊กในโปรแกรมและคุณเผลอส่งจำนวนเงินที่ติดลบเข้ามาเพื่อฝาก คุณจะได้รับ runtime exception ดังนี้:

Exception in thread "main"...
Caused by: java.lang.AssertionError: Assert failed: (> amount 0.0)

ในทำนองเดียวกัน ฟังก์ชันนี้กำหนดว่าบัญชีที่ระบุจะต้องเปิดอยู่และถูกต้อง หากไม่เป็นเช่นนั้น คุณจะเห็น exception ดังนี้แทน:

Exception in thread "main"...
Caused by: java.lang.AssertionError: Assert failed: (account-open? account-id)

ภาษาอื่นๆ ก็มีฟีเจอร์ที่แม้ว่าจะไม่ได้ออกแบบมาสำหรับ DBC โดยตรง แต่ก็สามารถนำมาใช้ให้เกิดประโยชน์ได้ เช่น Elixir ที่ใช้ guard clauses ในการเลือกเรียกฟังก์ชันตามเงื่อนไขที่กำหนดไว้ในแต่ละส่วน (bodies):

​defmodule​ Deposits ​do​
​def​ accept_deposit(account_id, amount) ​when​ (amount > 100000) ​do​
​# Call the manager!​
​end​
​def​ accept_deposit(account_id, amount) ​when​ (amount > 10000) ​do​
​# Extra Federal requirements for reporting​
​# Some processing...​
​end​
​def​ accept_deposit(account_id, amount) ​when​ (amount > 0) ​do​
​# Some processing...​
​end​
​end​

ในกรณีนี้ การเรียกใช้ accept_deposit ด้วยยอดเงินที่มากพออาจจะไปเรียกขั้นตอนและการประมวลผลเพิ่มเติม แต่ถ้าลองเรียกด้วยจำนวนเงินที่น้อยกว่าหรือเท่ากับศูนย์ คุณจะได้รับ exception แจ้งเตือนว่าทำไม่ได้:

** (FunctionClauseError) no function clause matching in Deposits.accept_deposit/2

นี่เป็นแนวทางที่ดีกว่าการแค่เช็คอินพุต เพราะในกรณีนี้ คุณจะไม่สามารถเรียกฟังก์ชันนี้ได้เลยหากอาร์กิวเมนต์อยู่นอกช่วงที่กำหนดไว้

Tip 37 Design with Contracts

ในหัวข้อที่ 10 ​_Orthogonality_​ เราเคยแนะนำให้เขียนโค้ดที่ “ขี้อาย” (shy code) แต่สำหรับหัวข้อนี้เราเน้นที่โค้ดที่ “ขี้เกียจ” (lazy code): คือจงเข้มงวดในสิ่งที่คุณจะยอมรับเข้ามาก่อนจะเริ่มทำงาน และสัญญา (promise) ว่าจะคืนค่าให้น้อยที่สุดเท่าที่จะเป็นไปได้ จำไว้ว่าถ้าสัญญาของคุณระบุว่าจะยอมรับทุกอย่างและสัญญาว่าจะทำให้ทุกอย่าง คุณก็จะต้องเขียนโค้ดเยอะมาก!

ไม่ว่าจะเป็นภาษาโปรแกรมประเภทไหน ไม่ว่าจะเป็นแบบ functional, object-oriented หรือ procedural ก็ตาม DBC จะบังคับให้คุณต้อง “คิด”

DBC และ Test-Driven Development

Design by Contract ยังจำเป็นอยู่อีกไหมในโลกที่เหล่านักพัฒนามีการทำ unit testing, test-driven development (TDD), property-based testing หรือ defensive programming อยู่แล้ว?

คำตอบสั้นๆ คือ “ใช่”

DBC และการทำ testing เป็นแนวทางที่แตกต่างกันสำหรับเรื่องความถูกต้องของโปรแกรม (program correctness) ทั้งคู่ต่างมีคุณค่าและมีการใช้งานในสถานการณ์ที่แตกต่างกัน โดย DBC มีข้อดีหลายอย่างที่เหนือกว่าการทำ testing ในบางแง่มุม ดังนี้:

  • DBC ไม่จำเป็นต้องมีการ setup หรือทำ mocking
  • DBC กำหนดพารามิเตอร์สำหรับความสำเร็จหรือความล้มเหลวครอบคลุมทุกกรณี ในขณะที่การทำ testing สามารถมุ่งเป้าได้เพียงกรณีเฉพาะทีละกรณีเท่านั้น
  • TDD และการทดสอบอื่นๆ จะเกิดขึ้นเฉพาะในช่วง “เวลาทดสอบ” (test time) ภายใน build cycle แต่ DBC และการทำ assertions นั้นจะอยู่ตลอดไป ทั้งในช่วงการออกแบบ การพัฒนา การใช้งานจริง (deployment) และการบำรุงรักษา
  • TDD ไม่ได้เน้นที่การตรวจสอบ internal invariants ภายในโค้ดที่กำลังทดสอบ แต่มันเป็นแบบ black-box style ที่เน้นเช็ค public interface
  • DBC มีประสิทธิภาพมากกว่า (และเป็นแบบ DRY กว่า) การทำ defensive programming ที่ทุกคนต้องคอยตรวจสอบข้อมูลซ้ำไปซ้ำมาเพราะไม่แน่ใจว่าคนอื่นเช็คไปแล้วหรือยัง

TDD เป็นเทคนิคที่ดีมาก แต่ก็เหมือนกับเทคนิคอื่นๆ มันอาจจะทำให้คุณจดจ่ออยู่แต่กับ “เส้นทางที่สวยงาม” (happy path) จนลืมโลกแห่งความเป็นจริงที่เต็มไปด้วยข้อมูลแย่ๆ, ผู้ไม่ประสงค์ดี, เวอร์ชันที่ผิดพลาด และสเปกที่ห่วยแตก

Class Invariants และ Functional Languages

มันเป็นเรื่องของชื่อเรียก Eiffel เป็นภาษาแนว object-oriented ดังนั้น Meyer จึงตั้งชื่อแนวคิดนี้ว่า “class invariant” แต่จริงๆ แล้วมันกว้างกว่านั้น สิ่งที่แนวคิดนี้อ้างถึงจริงๆ ก็คือ “สถานะ” (state) ในภาษา object-oriented สถานะจะถูกผูกเข้ากับ instance ของ class แต่ภาษาอื่นๆ ก็มีสถานะเหมือนกัน

ในภาษา functional คุณมักจะส่ง state ไปยังฟังก์ชันและรับ state ที่อัปเดตแล้วกลับมาเป็นผลลัพธ์ คอนเซปต์ของ invariants จึงมีประโยชน์อย่างยิ่งในสถานการณ์เหล่านี้เช่นกัน

การนำ DBC ไปใช้ (Implementing DBC)

เพียงแค่ระบุว่าช่วงของข้อมูลอินพุต (input domain range) คืออะไร, เงื่อนไขขอบเขต (boundary conditions) คืออะไร และสิ่งที่ routine สัญญาว่าจะส่งมอบ—หรือที่สำคัญกว่านั้นคือสิ่งที่มัน ไม่ได้ สัญญาว่าจะส่งมอบ—ก่อนที่คุณจะเขียนโค้ด ก็ถือเป็นก้าวที่ยิ่งใหญ่ในการเขียนซอฟต์แวร์ที่ดีขึ้นแล้ว การที่ไม่ระบุสิ่งเหล่านี้จะทำให้คุณกลับไปสู่การเขียนโปรแกรมแบบตามบุญตามกรรม (programming by coincidence) (ดูรายละเอียดใน หัวข้อนี้) ซึ่งเป็นจุดที่หลายโครงการเริ่มต้น ลงเอย และล้มเหลว

ในภาษาที่โค้ดไม่รองรับ DBC โดยตรง นี่อาจเป็นจุดสูงสุดที่คุณจะทำได้—ซึ่งนั่นก็ไม่ได้แย่เกินไป เพราะท้ายที่สุดแล้ว DBC คือเทคนิคการออกแบบ แม้ไม่มีการตรวจสอบโดยอัตนัติ คุณก็ยังสามารถระบุสัญญา (contract) ไว้ในโค้ดในรูปแบบของ comments หรือระบุไว้ใน unit tests และยังคงได้รับประโยชน์อย่างแท้จริง

Assertions

แม้การจดบันทึกสมมติฐาน (assumptions) เหล่านี้จะเป็นจุดเริ่มต้นที่ดี แต่คุณจะได้รับประโยชน์มหาศาลหากให้ compiler ช่วยตรวจสอบสัญญาให้คุณ คุณสามารถเลียนแบบ DBC ได้บางส่วนในบางภาษาโดยการใช้ assertions ซึ่งก็คือการเช็คเงื่อนไขทางตรรกะในขณะรันไทม์ (ดูหัวข้อที่ 25 ​_Assertive Programming_​) แล้วทำไมถึงทำได้เพียงแค่บางส่วนล่ะ? เราไม่สามารถใช้ assertions ทำทุกอย่างที่ DBC ทำได้เหรอ?

โชคร้ายที่คำตอบคือ ไม่ ในเบื้องต้น ภาษาแนว object-oriented มักจะไม่มีการรองรับการส่งต่อ (propagating) assertions ลงไปยังโครงสร้างการสืบทอด (inheritance hierarchy) นี่หมายความว่าถ้าคุณทำการ override method ของ base class ที่มีสัญญาอยู่ assertions ที่ใช้งานสัญญานนั้นจะไม่ถูกเรียกอย่างถูกต้อง (นอกจากคุณจะก๊อปปี้พวกมันมาใส่ในโค้ดใหม่ด้วยตนเอง) คุณต้องจำไว้เสมอว่าต้องเรียก class invariant (และ base class invariants ทั้งหมด) ด้วยตนเองก่อนจะจบการทำงานของทุก method ปัญหาพื้นฐานก็คือสัญญานั้นไม่ได้ถูกบังคับใช้โดยอัตโนมัติ

ในสภาพแวดล้อมอื่นๆ exception ที่เกิดจาก assertions สไตล์ DBC อาจจะถูกปิดการทำงานในระดับ global หรือถูกเพิกเฉยไปในโค้ดก็ได้

นอกจากนี้ ยังไม่มีคอนเซปต์เรื่องค่า “เก่า” (old values) แบบในตัว; นั่นคือค่าที่มีอยู่ก่อนจะเข้าสู่ method หากคุณใช้ assertions เพื่อบังคับใช้สัญญา คุณต้องเพิ่มโค้ดเข้าไปใน precondition เพื่อบันทึกข้อมูลที่คุณต้องการใช้ใน postcondition หากภาษานั้นอนุญาตให้ทำได้ ในภาษา Eiffel ซึ่งเป็นต้นกำเนิดของ DBC คุณสามารถใช้คำสั่ง old ได้เลย

สุดท้าย ระบบรันไทม์และไลบรารีทั่วไปไม่ได้ถูกออกแบบมาเพื่อรองรับสัญญา ดังนั้นการเรียกใช้จึงไม่มีการตรวจสอบ นี่เป็นความสูญเสียครั้งใหญ่ เพราะบ่อยครั้งที่ปัญหาถูกตรวจพบตรงรอยต่อระหว่างโค้ดของคุณกับไลบรารี (ดูรายละเอียดในหัวข้อที่ 24 ​_Dead Programs Tell No Lies_​)

ใครเป็นคนรับผิดชอบ? (Who's Responsible?)

ใครเป็นคนรับผิดชอบในการเช็ค precondition ระหว่างตัว caller หรือตัว routine ที่ถูกเรียก? หากมีการนำไปใช้เป็นส่วนหนึ่งของภาษา คำตอบคือไม่ใช่ทั้งคู่: เพราะ precondition จะถูกทดสอบเบื้องหลังหลังจากที่ caller เรียกใช้ routine แต่จะทำก่อนจะเข้าไปยังตัว routine นั้นๆ ดังนั้นหากมีการเช็คพารามิเตอร์ใดๆ ก็ตาม มันต้องถูกทำโดยตัว caller เพราะตัว routine จะไม่มีทางเห็นพารามิเตอร์ที่ฝ่าฝืน precondition ของมันได้เลย (สำหรับภาษาที่ไม่มีการรองรับในตัว คุณอาจต้องใช้ preamble และ/หรือ postamble หุ้ม routine ที่ถูกเรียกไว้เพื่อเช็ค assertions เหล่านี้)

สมมติว่าโปรแกรมหนึ่งอ่านตัวเลขจากคอนโซล คำนวณรากที่สอง (โดยการเรียก sqrt) และพิมพ์ผลลัพธ์ออกมา ฟังก์ชัน sqrt มี precondition อยู่ว่าอาร์กิวเมนต์ของมันต้องไม่เป็นลบ หากผู้ใช้ป้อนตัวเลขติดลบมาทางคอนโซล จะเป็นหน้าที่ของโค้ดส่วนที่เรียกที่จะต้องแน่ใจว่าตัวเลขนั้นจะไม่ถูกส่งไปยัง sqrt โดยโค้ดส่วนที่เรียกนี้มีทางเลือกมากมาย: มันอาจจะจบการทำงาน แจ้งเตือนและขอให้อ่านค่าใหม่ หรืออาจจะแปลงตัวเลขให้เป็นบวกแล้วเติม i เข้าไปในผลลัพธ์ที่ส่งคืนมาจาก sqrt ก็ได้ ไม่ว่าจะเลือกแบบไหน นี่ไม่ใช่ปัญหาของ sqrt อย่างแน่นอน

การระบุช่วงของโดเมน (domain) ของฟังก์ชันรากที่สองใน precondition ของ sqrt จะช่วยย้ายภาระหน้าที่ด้านความถูกต้องไปยังตัว caller ซึ่งเป็นที่ที่มันควรอยู่ จากนั้นคุณก็จะสามารถออกแบบ sqrt ได้อย่างมั่นใจว่าอินพุตจะอยู่ในช่วงที่กำหนดแน่นอน

DBC และการพังให้เร็ว (Crashing Early)

DBC เข้ากันได้อย่างดีกับแนวคิดการพังให้เร็ว (crashing early) (ดูหัวข้อที่ 24 ​_Dead Programs Tell No Lies_​) การใช้ assert หรือกลไก DBC เพื่อตรวจสอบความถูกต้องของ preconditions, postconditions และ invariants จะช่วยให้คุณพังได้เร็วขึ้นและรายงานข้อมูลที่แม่นยำเกี่ยวกับปัญหาได้มากขึ้น

ตัวอย่างเช่น สมมติว่าคุณมี method ที่ใช้คำนวณรากที่สอง มันต้องมี DBC precondition ที่จำกัดโดเมนเฉพาะจำนวนบวก ในภาษาที่รองรับ DBC หากคุณส่งพารามิเตอร์ที่ติดลบให้ sqrt คุณจะได้รับข้อความแจ้งเตือนข้อผิดพลาดที่ชัดเจน เช่น sqrt_arg_must_be_positive พร้อมกับ stack trace

นี่ดีกว่าทางเลือกในภาษาอื่นๆ อย่าง Java, C และ C++ ที่การส่งค่าติดลบไปยัง sqrt จะส่งคืนค่าพิเศษอย่าง NaN (Not a Number) กลับมา ซึ่งอาจจะผ่านไปอีกนานกว่าคุณจะนำ NaN นั้นไปคำนวณเลขต่อและพบกับผลลัพธ์ที่น่าประหลาดใจ

การพังให้เร็วตรงจุดที่เกิดปัญหานั้นช่วยให้การค้นหาและวินิจฉัยปัญหาง่ายขึ้นมาก

Semantic Invariants

คุณสามารถใช้ semantic invariants เพื่ออธิบายความต้องการที่ไม่สามารถฝ่าฝืนได้ เปรียบเสมือน “สัญญาทางปรัชญา” (philosophical contract)

เราเคยเขียนระบบสลับรายการธุรกรรมบัตรเดบิต ความต้องการที่สำคัญอย่างหนึ่งคือ ผู้ใช้บัตรเดบิตไม่ควรถูกทำรายการซ้ำสองครั้งในบัญชีเดิม กล่าวอีกนัยหนึ่งคือ ไม่ว่าจะเกิดข้อผิดพลาดประเภทใดก็ตาม ความผิดพลาดนั้นควรจะลงเอยที่การไม่ประมวลผลธุรกรรมนั้นเลย ดีกว่าการประมวลผลธุรกรรมซ้ำซ้อน

กฎง่ายๆ ข้อนี้ที่มาจากความต้องการโดยตรง ได้พิสูจน์แล้วว่ามีประโยชน์มากในการจัดการกับสถานการณ์การกู้คืนจากข้อผิดพลาดที่ซับซ้อน และช่วยชี้นำการออกแบบและนำไปใช้งานจริงในหลายๆ ส่วน

จงระวังอย่าสับสนระหว่างความต้องการที่เป็นกฎเกณฑ์ตายตัวที่ไม่สามารถฝ่าฝืนได้ กับนโยบาย (policy) ที่อาจเปลี่ยนแปลงได้เมื่อมีการเปลี่ยนคณะผู้บริหาร นั่นคือสาเหตุที่เราใช้คำว่า semantic invariants—ซึ่งมันจะต้องเป็นใจความสำคัญของสิ่งนั้นจริงๆ ไม่ใช่เปลี่ยนไปตามอารมณ์ของนโยบาย (ซึ่งนั่นเป็นหน้าที่ของกฎทางธุรกิจที่ยืดหยุ่นกว่า)

เมื่อคุณพบความต้องการที่เข้าข่ายดังกล่าว จงทำให้มันเป็นส่วนหนึ่งที่ทุกคนรับรู้ ไม่ว่าจะอยู่ในรูปแบบของรายการในเอกสารสเปก หรือแค่กระดาษโน้ตแผ่นใหญ่บนไวท์บอร์ดที่ทุกคนมองเห็น พยายามระบุให้ชัดเจนและไม่คลุมเครือ ตัวอย่างเช่น ในกรณีบัตรเดบิต เราอาจเขียนไว้ว่า

หากจะผิดพลาด ให้ผิดพลาดในทางที่เป็นผลดีต่อผู้บริโภค (Err in favor of the consumer.)

นี่เป็นข้อความที่ชัดเจน กระชับ และไม่คลุมเครือ ซึ่งสามารถนำไปปรับใช้ได้ในหลายๆ ส่วนของระบบ มันคือสัญญาของเราที่มีต่อผู้ใช้งานระบบทุกคน และเป็นสิ่งที่การันตีพฤติกรรมของระบบ

Dynamic Contracts และ Agents

จนถึงตอนนี้ เราได้พูดถึงสัญญา (contracts) ในฐานะของข้อกำหนดที่ตายตัวและเปลี่ยนแปลงไม่ได้ แต่ในโลกของ autonomous agents (เอเจนต์ที่ทำงานด้วยตนเอง) ไม่จำเป็นต้องเป็นเช่นนั้นเสมอไป ตามนิยามของ “autonomous” แล้ว agents มีอิสระที่จะปฏิเสธคำขอที่พวกมันไม่อยากทำตาม พวกมันมีอิสระที่จะเจรจาสัญญาใหม่ (renegotiate)—“ฉันทำอย่างนั้นให้ไม่ได้ แต่ถ้าคุณให้สิ่งนี้กับฉันแทน ฉันอาจจะทำอย่างอื่นให้ได้”

แน่นอนว่าระบบใดๆ ที่พึ่งพาเทคโนโลยี agent ย่อมมีความเชื่อมโยงอย่างวิกฤตกับการจัดทำสัญญา แม้ว่าสัญญาเหล่านั้นจะถูกสร้างขึ้นแบบไดนามิกก็ตาม

ลองจินตนาการดูสิ: หากมีคอมโพเนนต์และ agents มากพอที่สามารถเจรจาสัญญาของพวกมันเองเพื่อให้บรรลุเป้าหมายร่วมกันได้ เราอาจแก้ปัญหาวิกฤตด้านความเร็วในการผลิตซอฟต์แวร์ได้ โดยปล่อยให้ซอฟต์แวร์แก้ปัญหานั้นให้เราแทน

แต่ถ้าเรายังใช้สัญญาด้วยมือไม่ได้ เราก็ไม่มีทางนำพวกมันไปใช้อัตโนมัติได้หรอก ดังนั้น ครั้งหน้าที่คุณออกแบบซอฟต์แวร์สักชิ้น อย่าลืมออกแบบสัญญาสำหรับซอฟต์แวร์นั้นด้วย

หัวข้อที่เกี่ยวข้อง (Related Sections Include)

  • Topic 24, ​_Dead Programs Tell No Lies_​
  • Topic 25, ​_Assertive Programming_​
  • Topic 38, ​_Programming by Coincidence_​
  • Topic 42, ​_Property-Based Testing_​
  • Topic 43, ​_Stay Safe Out There_​
  • Topic 45, ​_The Requirements Pit_​

ความท้าทาย (Challenges)

  • ประเด็นที่น่าขบคิด: หาก DBC ทรงพลังขนาดนี้ ทำไมมันถึงไม่ถูกนำไปใช้อย่างกว้างขวางล่ะ? การคิดสัญญาขึ้นมามันยากเกินไปเหรอ? หรือมันทำให้คุณต้องคิดถึงปัญหาที่คุณอยากจะมองข้ามมันไปก่อนในตอนนี้? มันบังคับให้คุณต้อง “คิด” ใช่ไหม!? เห็นได้ชัดเลยว่านี่คือเครื่องมือที่อันตราย!

แบบฝึกหัด (Exercises)

แบบฝึกหัด 14 (คำตอบที่เป็นไปได้)

จงออกแบบ interface สำหรับเครื่องปั่นในห้องครัว ในอนาคตมันจะเป็นเครื่องปั่นที่ทำงานผ่านเว็บและรองรับ IoT แต่ตอนนี้เราต้องการแค่ interface เพื่อควบคุมมันเท่านั้น มันมีระดับความเร็ว 10 ระดับ (0 หมายถึงปิด) คุณไม่สามารถเปิดใช้งานขณะที่มันว่างเปล่าได้ และคุณสามารถเปลี่ยนความเร็วได้ทีละหนึ่งหน่วยเท่านั้น (เช่น จาก 0 เป็น 1, และจาก 1 เป็น 2 แต่ห้ามข้ามจาก 0 ไป 2)

นี่คือ methods ต่างๆ จงเพิ่ม pre- และ postconditions ที่เหมาะสม รวมถึง invariant เข้าไปด้วย

int getSpeed()
void setSpeed(int x)
boolean isFull()
void fill()
void empty()

แบบฝึกหัด 15 (คำตอบที่เป็นไปได้)

มีตัวเลขกี่จำนวนในชุดตัวเลข 0, 5, 10, 15, …, 100?