Test-Driven Development (TDD): คิดอย่างชัดเจนเหมือนนักวิทยาศาสตร์, ทำอย่างประณีตเหมือนช่างไม้

สวัสดีครับผม

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

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

มาวันหนึ่งไปเจอท่านอาจารย์ Leslie Lamport เจ้าของงาน Temporal logic of actions ซึ่งท่านแนะนำให้เราเขียน code โดยให้คิดเหมือนนักวิทยาศาสตร์ คือ นักวิทยาศาสตร์ เขาจะมองโลกความเป็นจริง แล้วอธิบายมันออกมาเป็นแบบจำลองคณิตศาสตร์เสมอ (ซึ่งผมถนัดอยู่แล้ว) พวกเขาไม่เคยงงกันเองในคำอธิบาย เพื่อแก้ปัญหาบนโลกจริงที่สลับซับซ้อนมากๆเลย ถ้าพวกเขาไม่เห็นด้วยกัน พวกเขาก็แค่เอาแบบจำลองคณิตศาสตร์นั้นไปพิสูจน์หาความจริงให้ได้กันเท่านั้น เพราะพวกเขาใช้คณิตศาสตร์อธิบาย แล้วพิสูจน์ความถูกต้องต้องด้วยสถิติศาสตร์ กันไงละครับ

เช่นกันครับ อาจารณ์ท่าน แนะนำให้โปรแกรมเมอร์เขียน Specifications เพื่ออธิบายปัญหาก่อน แล้วนำมันไปเขียน code เพราะ Specifications นั้นอธิบายปัญหาด้วยภาษาคนธรรมดาที่ครบถ้วนสมบูรณ์แล้ว ได้อย่างชัดเจน (cleanly) มากกว่า ภาษา code ที่สับสน ซับซ้อนกว่า ไม่มีระเบียบ ใครนึกอยากจะเติมอะไรเข้าไปก็เติม ทั้งๆที่ไม่เกี่ยวอะไรกับปัญหาเลยก็ทำได้แบบนี้ แต่ Specifications นั้นทำไม่ได้

ใน Specifications จะอธิบายเฉพาะปัญหาจริงๆเท่านั้นว่า มันคืออะไร (What/Who/Why) มีอะไรบ้าง ซึ่งประกอบด้วย ชื่อปัญหา, Domain/Codomain, Range, States และ ขั้นตอนแก้ไขปัญหานั้น ทำอย่างไร (How หรือ Algorithm) ซึ่งแสดงออกมาเป็นขั้นตอน (Step) ไปจนสามารถแก้ไขปัญหานั้นได้ ออกมาอยู่ในขอบเขต States ที่จำกัดไว้เท่านั้น

เมื่อเขียน Spec ใช้อธิบายได้ชัดเจนถูกต้องแล้ว ขั้นต่อมา จึงเขียน Code เมื่อนำแนวคิดไปใช้ในเทคนิค TDD ผมก็จะได้ว่า การเขียน Spec มันก็คือขั้นตอน Red โดยขั้นตอนเขียน spec นี้ก็คือปรัชญา Test-First และ ในขั้นตอนเขียน Code หรือ Implement ก็เป็นขั้นตอน Green และ Refactors ของผม ซึ่งผมก็จะเขียนโปรแกรมที่ สร้าง Code บน สถาปัตยกรรมซอฟแวร์ที่ผมต้องการได้จาก Spec ต่อไป

เอาล่ะเพื่อมิให้เสียเวลา ผมจะแสดงตัวอย่าง แก้ปัญหา Fizz buzz นะขอรับ

ปัญหาคืออะไร (What) – Red / Test-First

Fizz buzz เป็นเกมสำหรับเด็กฝึกหารเลข วิธีเล่นคือ นำผู้เล่นทั้งหมดยืนเป็นวงกลม แล้วให้พูดเลขบอกทุกคน เริ่มจาก 1 คนต่อไป ให้บวกเลขไปอีก 1 แล้วพูดเลขออกมาดังๆให้ทุกคนในวงได้ยิน วนต่อไปเรื่อยๆ ถ้าเลขที่พูด หาร 3 ลงตัว ให้พูด “fizz”, ถ้าหารด้วย 5 ลงตัวให้พูดว่า “buzz” และถ้าหาร ทั้ง 3 และ 5 ลงตัวให้พูดว่า “fizz buzz”

เมื่อวิเคราะห์ปัญหาแล้ว เราจะได้ Input, Output และ States ของปัญหานี้ออกมา ดังนี้

เราจะได้ Input ของปัญหานี้ ในทางคณิตศาสตร์ เรียกว่า Domain ให้ N เป็นเซต เลขจำนวนเต็มที่ผู้เล่นจะพูด

N = { 1, 2, 3, 4, … } และ

Output ทั้งหมดที่เป็นไปได้ ในทางคณิศาสตร์เรียกว่า Codomain ให้ W เป็นเซตของ คำพูดที่ผู้เล่นจะต้องบอกผู้เล่นคนอื่นๆ

W = { “fizz”, “buzz”, “fizz buzz”, “หนึ่ง”, “สอง”, “สาม”, “สี่”, … }

States คือ เซตของสถานะ(State) ที่เป็นไปได้ทั้งหมด ของปัญหานี้

State คือ คู่ลำดับของ (d, r) โดยที่ d เป็นสมาชิกของ Domain, r เป็นสมาชิกของ Codomain

ดังนั้น เราจะได้ S คือ เซตของ (N = n, W = w) ที่เป็นไปได้ทั้งหมดของปัญหาเกม Fizz buzz ก็คือ

S = { (1, “หนึ่ง”), (2, “สอง”), (3, “fizz”), (4, “สี่”), (5, “buzz”), (6, “fizz”), … (14, “สิบสี่”), (15, “fizz buzz”) … }

นักคณิตศาสตร์เรียกผลลัพย์ของ สามาชิกที่เป็นไปได้ทั้งหมดใน States ว่า Range กล่าวคือ Range เป็นซับเซตของ Codomain นั่นเอง

ดังนั้นเซต Range ของปัญหา Fizz buzz ก็คือ

{ “fizz”, “buzz”, “fizz buzz”, “หนึ่ง”, “สอง”, “สี่”, “เจ็ด”, … “สิบสี่”, “สิบหก” … }

ปัญหานี้แก้ไขอย่างไร (How) – Red / Test-First

ขั้นตอนแก้ปัญหานี้

1. เริ่มต้นให้ n เป็นค่าที่รับเข้ามา, w = ค่าว่าง | n เป็นสมาชิกของ N, w เป็นสมาชิกของ W และ s เป็นสมาชิกของ S

2. ถ้า n หาร 3 และ หาร 5 ลงตัว ให้ w = “fizz buzz”, ไม่ใช่ให้ตรวจสอบข้อต่อไป

3. ถ้า n หาร 3 ลงตัว ให้ w = “fizz”,  ไม่ใช่ให้ตรวจสอบข้อต่อไป

4. ถ้า n หาร 5 ลงตัว ให้ w = “buzz”, จบ

5. ถ้า w เป็นค่าว่าง ให้ w = แสดงคำพูดของตัวเลข n

6. ให้ s = (n, w) แสดง s

เพียงสองขั้นตอนนี้ ปัญหาคืออะไร และ แก้ไขได้อย่างไร คิดแบบนักวิทยาศาสตร์ ได้ชัดเจน ด้วยภาษาเขียนธรรมดาๆ ถ้า ขั้นตอนไหนไม่ ชัดเจนก็ใช้ สัญลักษณ์ ทางคณิตศาสตร์อธิบายได้ชัดเจนกว่า

ลองมาดูตัวอย่าง ทำให้เกิดผล (implement) คือเปลี่ยนเป็น code ให้เครื่องทำงานครับ ซึ่งขั้นตอนนี้เราสามารถเขียนเครื่องมือแปลง spec ของเราไปเป็น code ภาษาอะไร, สวยแบบไหน pattern อะไร, แพรทฟอมร์อะไร, OS อะไร หรือ สถาปัตยกรรมซอฟแวร์ อะไรก็ได้ตามที่เราต้องการได้เลย ไม่ใช่ปัญหายากสำหรับผมครับ

ในตัวอย่าง code ง่ายๆเขียนด้วยมือไปก่อน ผมตั้งใจให้เกิดความเข้าใจก่อน เครื่องมือเป็นเรื่องรอง นะครับ

ตัวอย่าง ทำให้เกิดผลด้วย Code C# – Green & Refactors

ตัวอย่าง code ขอจำกัดขอบเขตของ States ที่เป็นไปได้ทั้งหมด 15 ตัวเลข ได้ state แจกแจงออกมาแบบนี้ครับ

var S = new Dictionary<uint, string>();
S.Add(1, “หนึ่ง”);
S.Add(2, “สอง”);
S.Add(3, “fizz”);
S.Add(4, “สี่”);
S.Add(5, “buzz”);
S.Add(6, “fizz”);
S.Add(7, “เจ็ด”);
S.Add(8, “แปด”);
S.Add(9, “fizz”);
S.Add(10, “buzz”);
S.Add(11, “สิบเอ็ด”);
S.Add(12, “fizz”);
S.Add(13, “สิบสาม”);
S.Add(14, “สิบสี่”);
S.Add(15, “fizz buzz”);

ส่วนนี้ก็คือ ผลลัพย์ที่เป็นไปได้ทั้งหมดที่เราสนใจตอนนี้ ของปัญหา Fizz buzz เกม ถ้า code ของเราให้คำตอบที่ นอก states มันเป็นสัญญานบอกเราว่า Code เราทำงานผิด จะต้องถูกแก้ไข

ก่อนอื่นต้องเขียน code ฟังค์ชั่น ที่ใช้แสดง คำพูดของตัวเลข ในที่นี้ผมเขียนง่ายๆจำกัดไว้ถึง 15 ตัวเลขก่อน แบบนี้

string say(uint n)
{

switch (n)
{

case 1: return “หนึ่ง”;
case 2: return “สอง”;
case 3: return “สาม”;
case 4: return “สี่”;
case 5: return “ห้า”;
case 6: return “หก”;
case 7: return “เจ็ด”;
case 8: return “แปด”;
case 9: return “เก้า”;
case 10: return “สิบ”;
case 11: return “สิบเอ็ด”;
case 12: return “สิบสอง”;
case 13: return “สิบสาม”;
case 14: return “สิบสี่”;
case 15: return “สิบห้า”;
default:throw new NotImplementedException(“เราอ่านเลขนี้ไม่ออก”);

}

}

อีก code ฟังค์ชั่น หนึ่งคือส่วนงานจริงๆ ที่จะให้ค่าผลลัพย์เป็น state ของ Fizz buzz เกม ของเราครับ แบบนี้

Tuple<uint, string> fizzBuzz(uint n)
{

string w = string.Empty;

if (n % (3 * 5) == 0) w = “fizz buzz”; else

if (n % 3 == 0) w = “fizz”; else

if (n % 5 == 0) w = “buzz”;

if (string.IsNullOrEmpty(w)) w = say(n);

return new Tuple<uint, string>(n, w);

}

ส่วน code สุดท้าย คือการทดสอบ ว่า fizzBuzz ของเราทำงานได้ถูกต้องรึเปล่า ขอบเขตถึงเลข 15 ครับ ได้ code แบบนี้

[TestMethod]
public void TestFizzBuzzLimit15()
{

//กำหนด states ของปัญหาทั้งหมด
var S = new Dictionary<uint, string>();
S.Add(1, “หนึ่ง”);
S.Add(2, “สอง”);
S.Add(3, “fizz”);
S.Add(4, “สี่”);
S.Add(5, “buzz”);
S.Add(6, “fizz”);
S.Add(7, “เจ็ด”);
S.Add(8, “แปด”);
S.Add(9, “fizz”);
S.Add(10, “buzz”);
S.Add(11, “สิบเอ็ด”);
S.Add(12, “fizz”);
S.Add(13, “สิบสาม”);
S.Add(14, “สิบสี่”);
S.Add(15, “fizz buzz”);

for (uint i = 1; i <= 15; i++)
{

var state = fizzBuzz(i);

//การตรวจสอบ state ที่ผ่านฟังค์ชั่นงานของเรา fizzBuzz แล้วกับ S ที่เป็นไปได้จาก spec
Assert.AreEqual(S[i], state.Item2);

}

}

เอาละเราลองมา เล่นกับ code ของเราเพิ่มดีกว่า เช่น

ถ้าเราเพิ่มตัวเลขเข้าไปอีกล่ะจะ เกิดอะไรขึ้น (What If)

อะไรจะขึ้น ถ้า เราได้เลข 16 ?

ก็ต้อง พูดว่า “สิบหก” หนะสิ ถามได้

ใช่ครับ state ที่เป็นไปได้เราเพิ่มขึ้นแล้วไงครับ แบบนี้

var S = new Dictionary<uint, string>();
S.Add(1, “หนึ่ง”);
S.Add(2, “สอง”);
S.Add(3, “fizz”);
S.Add(4, “สี่”);
S.Add(5, “buzz”);
S.Add(6, “fizz”);
S.Add(7, “เจ็ด”);
S.Add(8, “แปด”);
S.Add(9, “fizz”);
S.Add(10, “buzz”);
S.Add(11, “สิบเอ็ด”);
S.Add(12, “fizz”);
S.Add(13, “สิบสาม”);
S.Add(14, “สิบสี่”);
S.Add(15, “fizz buzz”);
S.Add(16, “สิบหก”);

code ทดสอบ ของเราต้องเพิ่มขึ้นไป 16 เพื่อให้ครอบคลุม state ใหม่ที่เพิ่มเข้ามา แบบนี้

for (uint i = 1; i <= 16; i++)
{

var state = fizzBuzz(i);

//การตรวจสอบ state ที่ผ่านฟังค์ชั่นงานของเรา fizzBuzz แล้วกับ S ที่เป็นไปได้จาก spec
Assert.AreEqual(S[i], state.Item2);

}

เอาละลอง run ทดสอบดูครับ … แน่นอนเกิดข้อผิดพลาดใช่มั้ยละ ซึ่งมันก็คือกลับไปขั้นตอนแดงๆ หรือผิดพลาดอีกครั้งแล้ว ลองกลับไปแก้ไข code งานของเราแล้ว ทำให้มันเขียวให้ได้… ฝากไว้เป็นการบ้านนะครับ 😉

ผมขอจบบล็อกไว้เพียงเท่านี้ หวังว่าคงจะเป็นประโยชน์บ้าง

ขอบคุณครับ

#:P

Advertisements