นี่เป็นคำถามที่เป็นนามธรรมและเฉพาะเจาะจงกับโดเมนของปัญหามากที่สุดในหนังสือเล่มนี้ ตอนแรกผมเกือบไม่ได้เพิ่มมันเข้าไปเพราะเหตุนั้น แต่ความจริงก็คือ ไม่ว่าเราจะใช้แนวทางการออกแบบแบบไหนหรือเผชิญกับข้อจำกัดอะไรบ้าง ก็ยังมีเทคนิคบางอย่างที่เราสามารถใช้เพื่อทำให้งานง่ายขึ้นได้
มาพูดถึงบริบทกันดีกว่า ใน legacy code หนึ่งในสิ่งที่สำคัญที่สุดคือเรายังไม่มี Test ครอบคลุมโค้ดส่วนใหญ่ของเรา ที่แย่กว่านั้นคือการจะสร้าง Test เหล่านั้นขึ้นมาอาจเป็นเรื่องยาก หลายทีมมักถูกล่อลวงให้หันไปใช้เทคนิคใน Chapter 6 , I Don't Have Much Time and I Have to Change It เพราะเหตุนี้ เราสามารถใช้เทคนิคที่อธิบายไว้ในนั้น (sprouting และ wrapping) เพื่อเพิ่มโค้ดโดยไม่มี Test ได้ แต่มันก็มีอันตรายบางอย่างนอกเหนือจากที่เห็นได้ชัด อย่างแรกคือ เมื่อเรา sprout หรือ wrap เราไม่ได้แก้ไขโค้ดเดิมมากนัก ดังนั้นโค้ดก็จะไม่ดีขึ้นในระยะเวลาหนึ่ง อีกอันตรายหนึ่งคือ duplication ถ้าโค้ดที่เราเพิ่มเข้าไปซ้ำกับโค้ดที่มีอยู่ในพื้นที่ที่ไม่มี Test มันอาจจะนอนเฉยๆ และกลายเป็นปัญหา ที่แย่กว่านั้นคือเราอาจไม่รู้ตัวเลยว่ากำลังเกิด duplication จนกระทั่งเราแก้ไขไปไกลมากแล้ว อันตรายสุดท้ายคือความกลัวและการยอมจำนน: ความกลัวว่าเราไม่สามารถเปลี่ยนโค้ดบางส่วนให้ใช้งานได้ง่ายขึ้น และการยอมจำนน เพราะพื้นที่โค้ดทั้งหมดไม่ได้ดีขึ้นเลย ความกลัวขัดขวางการตัดสินใจที่ดี ส่วน sprouts และ wraps ที่ทิ้งไว้ในโค้ดก็เป็นเครื่องเตือนใจถึงสิ่งนั้น
โดยทั่วไปแล้ว การเผชิญหน้ากับปัญหาโดยตรงดีกว่าหลบหนี ถ้าเราสามารถทำให้โค้ดอยู่ภายใต้ Test ได้ เราก็จะใช้เทคนิคในบทนี้เพื่อก้าวต่อไปในทางที่ดีได้ ถ้าคุณต้องการหาวิธีสร้าง Test ให้ดูที่ Chapter 13 , I Need to Make a Change, but I Don't Know What Tests to Write ถ้า Dependency ต่าง ๆ ขวางทางคุณ ให้ดูที่ Chapter 9 , I Can't Get This Class into a Test Harness และ Chapter 10 , I Can't Run This Method in a Test Harness .
เมื่อเรามี Test อยู่แล้ว เราก็อยู่ในสถานะที่ดีขึ้นในการเพิ่มฟีเจอร์ใหม่ เรามีรากฐานที่มั่นคง
Test-Driven Development (TDD) (การพัฒนาแบบ Test-Driven)
เทคนิคการเพิ่มฟีเจอร์ที่ทรงพลังที่สุดที่ผมรู้จักคือ test-driven development (TDD) พูดง่ายๆ คือ มันทำงานแบบนี้: เราจินตนาการ method ที่จะช่วยแก้ปัญหาบางส่วน และจากนั้นเราเขียน test case ที่ล้มเหลวขึ้นมาสำหรับมัน method ยังไม่มีอยู่จริง แต่ถ้าเราสามารถเขียน Test สำหรับมันได้ แสดงว่าเราได้ทำให้ความเข้าใจของเราเกี่ยวกับสิ่งที่โค้ดที่เรากำลังจะเขียนควรทำนั้นชัดเจนขึ้นแล้ว
Test-driven development ใช้อัลกอริทึมเล็กๆ ดังนี้:
1. เขียน test case ที่ล้มเหลว
2. ทำให้มัน compile ผ่าน
3. ทำให้มันผ่าน
4. กำจัด duplication
5. ทำซ้ำ
นี่คือตัวอย่าง เรากำลังทำงานกับแอปพลิเคชันทางการเงิน และเราต้องการคลาสที่จะใช้คณิตศาสตร์ชั้นสูงเพื่อตรวจสอบว่าควรซื้อขายสินค้าโภคภัณฑ์บางชนิดหรือไม่ เราต้องการ Java class ที่คำนวณสิ่งที่เรียกว่า first statistical moment รอบจุดหนึ่ง เรายังไม่มี method ที่ทำอย่างนั้น แต่เรารู้ว่าเราสามารถเขียน test case สำหรับ method นั้นได้ เรารู้คณิตศาสตร์ ดังนั้นเราจึงรู้ว่าคำตอบควรเป็น -0.5 สำหรับข้อมูลที่เราเขียนใน Test
Write a Failing Test Case (เขียน Test Case ที่ล้มเหลว)
นี่คือ test case สำหรับฟังก์ชันที่เราต้องการ
public void testFirstMoment() {
InstrumentCalculator calculator = new InstrumentCalculator();
calculator.addElement(1.0);
calculator.addElement(2.0);
assertEquals(-0.5, calculator.firstMomentAbout(2.0), TOLERANCE);
}
Get It to Compile (ทำให้มัน Compile ผ่าน)
Test ที่เราเพิ่งเขียนไปนั้นดี แต่คอมไพล์ไม่ผ่าน เรายังไม่มี method ชื่อ firstMomentAbout ใน InstrumentCalculator แต่เราเพิ่มมันเป็น method ว่างๆ เราต้องการให้ Test ล้มเหลว ดังนั้นเราจึงให้มันคืนค่า double NaN (ซึ่งแน่นอนว่าไม่ใช่ค่าที่คาดหวังคือ -0.5 )
public class InstrumentCalculator
{
double firstMomentAbout(double point) {
return Double.NaN;
}
...
}
Make It Pass (ทำให้มันผ่าน)
เมื่อมี Test นั้นแล้ว เราก็เขียนโค้ดที่ทำให้มันผ่าน
public double firstMomentAbout(double point) {
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point;
}
return numerator / elements.size();
}
นี่เป็นโค้ดจำนวนมากที่ผิดปกติในการเขียนตอบสนองต่อ Test หนึ่งใน TDD โดยทั่วไปแล้วขั้นตอนจะเล็กกว่ามาก ถึงแม้ว่ามันจะขนาดเท่านี้ได้ถ้าคุณมั่นใจในอัลกอริทึมที่ต้องใช้
Remove Duplication (กำจัด Duplication)
เรามี duplication ตรงนี้ไหม? ไม่จริงๆ เราสามารถไปต่อที่เคสต่อไปได้
Write a Failing Test Case (เขียน Test Case ที่ล้มเหลว)
โค้ดที่เราเพิ่งเขียนทำให้ Test ผ่าน แต่มันจะใช้ได้ดีกับทุกกรณีแน่นอน ในคำสั่ง return เราอาจบังเอิญหารด้วย 0 โดยไม่ตั้งใจ เราควรทำอย่างไรในกรณีนั้น? เราจะคืนค่าอะไรเมื่อไม่มี element? ในกรณีนี้ เราต้องการโยน exception ออกมา ผลลัพธ์จะไร้ความหมายสำหรับเราถ้าไม่มีข้อมูลใน elements list ของเรา
Test ต่อไปนี้เป็นกรณีพิเศษ มันจะล้มเหลวถ้า InvalidBasisException ไม่ถูกโยนออกมา และมันจะผ่านถ้าไม่มี exception ใดถูกโยน หรือ exception อื่นถูกโยน เมื่อเราเรียกใช้มัน มันล้มเหลวเพราะ ArithmeticException ถูกโยนออกมาเมื่อเราหารด้วย 0 ใน firstMomentAbout .
public void testFirstMoment() {
try {
new InstrumentCalculator().firstMomentAbout(0.0);
fail("expected InvalidBasisException");
}
catch (InvalidBasisException e) {
}
}
Get It to Compile (ทำให้มัน Compile ผ่าน)
ในการทำเช่นนี้ เราต้องเปลี่ยน declaration ของ firstMomentAbout เพื่อให้มันโยน InvalidBasisException .
public double firstMomentAbout(double point)
throws InvalidBasisException
{
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point;
}
return numerator / elements.size();
}
แต่นั่นก็ยัง compile ไม่ผ่าน ข้อความ error จาก compiler บอกเราว่าเราต้องโยน exception จริงๆ ถ้ามันถูกระบุไว้ใน declaration ดังนั้นเราจึงเขียนโค้ดนั้น
public double firstMomentAbout(double point)
throws InvalidBasisException {
if (element.size() == 0)
throw new InvalidBasisException("no elements");
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point;
}
return numerator / elements.size();
}
Make It Pass (ทำให้มันผ่าน)
ตอนนี้ Test ของเราผ่านแล้ว
Remove Duplication (กำจัด Duplication)
ไม่มี duplication ในกรณีนี้
Write a Failing Test Case (เขียน Test Case ที่ล้มเหลว)
โค้ดชิ้นต่อไปที่เราต้องเขียนคือ method ที่คำนวณ second statistical moment รอบจุดหนึ่ง อันที่จริง มันก็แค่รูปแบบหนึ่งของอันแรก นี่คือ Test ที่นำเราไปสู่การเขียนโค้ดนั้น ในกรณีนี้ ค่าที่คาดหวังคือ 0.5 แทนที่จะเป็น -0.5 เราเขียน Test ใหม่สำหรับ method ที่ยังไม่มีอยู่: secondMomentAbout .
public void testSecondMoment() throws Exception {
InstrumentCalculator calculator = new InstrumentCalculator();
calculator.addElement(1.0);
calculator.addElement(2.0);
assertEquals(0.5, calculator.secondMomentAbout(2.0), TOLERANCE);
}
Get It to Compile (ทำให้มัน Compile ผ่าน)
เพื่อให้มัน compile ผ่าน เราต้องเพิ่มนิยามสำหรับ secondMomentAbout เราสามารถใช้เคล็ดลับเดียวกับที่เราใช้กับ method firstMomentAbout แต่ปรากฏว่าโค้ดสำหรับ moment ที่สองนั้นเป็นเพียงการแปรผันเล็กน้อยจากโค้ดของ moment แรก
บรรทัดนี้ใน firstMoment :
numerator += element - point;
ต้องกลายเป็นดังนี้ในกรณีของ second moment:
numerator += Math.pow(element – point, 2.0);
และมีรูปแบบทั่วไปสำหรับสิ่งนี้ n th statistical moment คำนวณโดยใช้นิพจน์นี้:
numerator += Math.pow(element – point, N);
โค้ดใน firstMomentAbout ทำงานได้เพราะ element – point ก็เหมือนกับ Math.pow(element – point, 1.0) .
ณ จุดนี้ เรามีทางเลือกสองสามทาง เราสามารถสังเกตเห็นลักษณะทั่วไปและเขียน method ทั่วไปที่รับค่า "about" point และค่า N จากนั้นเราสามารถแทนที่ทุกการใช้งาน firstMomentAbout(double) ด้วยการเรียก method ทั่วไปนั้น เราทำอย่างนั้นได้ แต่มันจะทำให้ผู้เรียกต้องส่งค่า N ด้วย ซึ่งเราไม่ต้องการให้ client ส่งค่าใดๆ สำหรับ N ได้ตามอำเภอใจ ดูเหมือนเรากำลังหลงทางกับความคิดนี้ เราควรหยุดไว้แค่นี้ก่อนและทำให้สิ่งที่เราเริ่มไว้เสร็จก่อน งานเดียวของเราตอนนี้คือทำให้มัน compile ผ่าน เราสามารถทำ generalization ทีหลังถ้าเรายังต้องการ
เพื่อให้มัน compile ผ่าน เราสามารถ copy method firstMomentAbout และเปลี่ยนชื่อมันเป็น secondMomentAbout :
public double secondMomentAbout(double point)
throws InvalidBasisException {
if (elements.size() == 0)
throw new InvalidBasisException("no elements");
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += element - point;
}
return numerator / elements.size();
}
Make It Pass (ทำให้มันผ่าน)
โค้ดนี้ทำให้ Test ล้มเหลว เมื่อมันล้มเหลว เราสามารถกลับไปแก้ไขให้มันผ่านโดยเปลี่ยนโค้ดเป็นดังนี้:
public double secondMomentAbout(double point)
throws InvalidBasisException {
if (elements.size() == 0)
throw new InvalidBasisException("no elements");
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += Math.pow(element – point, 2.0);
}
return numerator / elements.size();
}
คุณอาจตกใจกับการ cut/copy/paste ที่เราทำไป แต่เรากำลังจะกำจัด duplication อีกไม่นาน โค้ดที่เราเขียนนี้เป็นโค้ดใหม่ แต่เคล็ดลับการ copy โค้ดที่ต้องการและแก้ไขใน method ใหม่นั้นมีพลังมากในบริบทของ legacy code บ่อยครั้งเมื่อเราต้องการเพิ่มฟีเจอร์ลงในโค้ดที่แย่มาก ๆ การเข้าใจการปรับเปลี่ยนของเราจะง่ายกว่าถ้าเราใส่ไว้ในที่ใหม่และเห็นมันเคียงข้างกับโค้ดเก่า เราสามารถกำจัด duplication ทีหลังเพื่อรวมโค้ดใหม่เข้าไปในคลาสในทางที่ดีขึ้น หรือเราอาจจะทิ้งการปรับเปลี่ยนนั้นและลองวิธีอื่น โดยรู้ว่าเรายังมีโค้ดเก่าให้ดูและเรียนรู้จากมัน
Remove Duplication (กำจัด Duplication)
ตอนนี้เรามีทั้งสอง Test ที่ผ่านแล้ว เราต้องทำขั้นตอนต่อไป: กำจัด duplication เราจะทำยังไง?
วิธีหนึ่งคือการ extract ทั้ง body ของ secondMomentAbout เรียกมันว่า nthMomentAbout และให้ parameter N แก่มัน:
public double secondMomentAbout(double point)
throws InvalidBasisException {
return nthMomentAbout(point, 2.0);
}
private double nthMomentAbout(double point, double n)
throws InvalidBasisException {
if (elements.size() == 0)
throw new InvalidBasisException("no elements");
double numerator = 0.0;
for (Iterator it = elements.iterator(); it.hasNext(); ) {
double element = ((Double)(it.next())).doubleValue();
numerator += Math.pow(element – point, n);
}
return numerator / elements.size();
}
ถ้าเราเรียกใช้ Test ของเราตอนนี้ เราจะเห็นว่ามันผ่าน เราสามารถกลับไปที่ firstMomentAbout และแทนที่ body ของมันด้วยการเรียก nthMomentAbout :
public double firstMomentAbout(double point)
throws InvalidBasisException {
return nthMomentAbout(point, 1.0);
}
ขั้นตอนสุดท้ายนี้ การกำจัด duplication มีความสำคัญมาก เราสามารถเพิ่มฟีเจอร์ลงในโค้ดได้อย่างรวดเร็วและโหดร้ายด้วยการทำเช่น copy โค้ดทั้งบล็อก แต่ถ้าเราไม่กำจัด duplication ทีหลัง เราก็กำลังสร้างปัญหาและเพิ่มภาระในการบำรุงรักษา ในทางกลับกัน ถ้าเรามี Test อยู่แล้ว เราก็จะสามารถกำจัด duplication ได้อย่างง่ายดาย เราเห็นสิ่งนี้ชัดเจนที่นี่ แต่เหตุผลเดียวที่เรามี Test ก็เพราะเราใช้ TDD ตั้งแต่เริ่มต้น ใน legacy code Test ที่เราเขียนรอบโค้ดที่มีอยู่เมื่อเราใช้ TDD มีความสำคัญมาก เมื่อเรามี Test เหล่านี้แล้ว เราก็มีอิสระที่จะเขียนโค้ดใดๆ ที่จำเป็นในการเพิ่มฟีเจอร์ และเรารู้ว่าเราจะสามารถรวมมันเข้ากับโค้ดที่เหลือได้โดยไม่ทำให้สิ่งต่างๆ แย่ลง
TDD และ Legacy Code
สิ่งที่มีค่าที่สุดอย่างหนึ่งของ TDD คือมันช่วยให้เราจดจ่อกับสิ่งหนึ่งในแต่ละครั้ง เรากำลังเขียนโค้ดหรือ refactoring เราไม่เคยทำทั้งสองอย่างพร้อมกัน
การแยกแบบนี้มีค่าโดยเฉพาะใน legacy code เพราะมันช่วยให้เราเขียนโค้ดใหม่โดยแยกอิสระจากโค้ดเก่า
หลังจากที่เราเขียนโค้ดใหม่เสร็จ เราสามารถ refactor เพื่อกำจัด duplication ระหว่างมันกับโค้ดเก่าได้
สำหรับ legacy code เราสามารถขยายอัลกอริทึมของ TDD ได้ดังนี้:
0. ทำให้คลาสที่คุณต้องการเปลี่ยนอยู่ภายใต้ Test .
1. เขียน test case ที่ล้มเหลว
2. ทำให้มัน compile ผ่าน
3. ทำให้มันผ่าน ( พยายามอย่าเปลี่ยนโค้ดที่มีอยู่ขณะทำสิ่งนี้ )
4. กำจัด duplication
5. ทำซ้ำ
Programming by Difference (การเขียนโปรแกรมโดยอาศัยความแตกต่าง)
Test-driven development ไม่ได้ผูกติดอยู่กับ object orientation อันที่จริง ตัวอย่างในส่วนก่อนหน้านี้เป็นเพียงโค้ดเชิงกระบวนความ (procedural code) ที่ห่อหุ้มอยู่ในคลาส ใน OO เรามีอีกทางเลือกหนึ่ง เราสามารถใช้ inheritance เพื่อนำฟีเจอร์เข้ามาโดยไม่ต้องแก้ไขคลาสโดยตรง หลังจากที่เราเพิ่มฟีเจอร์แล้ว เราก็ค่อยหาว่าแท้จริงแล้วเราต้องการให้ฟีเจอร์นั้นถูกรวมเข้าด้วยกันแบบไหน
เทคนิคหลักในการทำสิ่งนี้เรียกว่า programming by difference มันเป็นเทคนิคที่ค่อนข้างเก่า ซึ่งถูกพูดถึงและใช้งานมากในยุค 1980s แต่ก็ตกความนิยมในยุค 1990s เมื่อคนในชุมชน OO จำนวนมากสังเกตเห็นว่า inheritance อาจเป็นปัญหาถ้าใช้มากเกินไป แต่การที่เราใช้ inheritance ในตอนแรกไม่ได้หมายความว่าเราต้องเก็บมันไว้ตลอดไป ด้วยความช่วยเหลือของ Test เราสามารถเปลี่ยนไปใช้โครงสร้างอื่นได้อย่างง่ายดายถ้า inheritance กลายเป็นปัญหา
นี่คือตัวอย่างที่แสดงว่ามันทำงานอย่างไร เรามี Java class ที่ผ่าน Test แล้วชื่อ MailForwarder ซึ่งเป็นส่วนหนึ่งของโปรแกรม Java ที่จัดการ mailing lists มันมี method ชื่อ getFromAddress ซึ่งมีหน้าตาแบบนี้:
private InternetAddress getFromAddress(Message message)
throws MessagingException {
Address [] from = message.getFrom ();
if (from != null && from.length > 0)
return new InternetAddress (from [0].toString ());
return new InternetAddress (getDefaultFrom());
}
จุดประสงค์ของ method นี้คือการดึงที่อยู่ "from" ของข้อความอีเมลที่ได้รับออกมาและส่งคืนเพื่อใช้เป็นที่อยู่ "from" ของข้อความที่จะถูกส่งต่อไปยังผู้รับในรายการ
มันถูกใช้ในที่เดียวเท่านั้น คือบรรทัดเหล่านี้ใน method ชื่อ forwardMessage :
MimeMessage forward = new MimeMessage (session);
forward.setFrom (getFromAddress (message));
ทีนี้ เราต้องทำอย่างไรถ้ามี requirement ใหม่? ถ้าเราต้องการรองรับ mailing lists ที่ไม่เปิดเผยตัวตน (anonymous) สมาชิกของรายการเหล่านี้สามารถโพสต์ได้ แต่ที่อยู่ "from" ของข้อความของพวกเขาควรถูกตั้งค่าเป็นที่อยู่อีเมลเฉพาะตามค่าของ domain (instance variable ของคลาส MessageFowarder ) นี่คือ test case ที่ล้มเหลวสำหรับการเปลี่ยนแปลงนั้น (เมื่อ Test ทำงาน ตัวแปร expectedMessage จะถูกตั้งค่าเป็นข้อความที่ MessageFowarder ส่งต่อ):
public void testAnonymous () throws Exception {
MessageForwarder forwarder = new MessageForwarder();
forwarder.forwardMessage (makeFakeMessage());
assertEquals ("anon-members@" + forwarder.getDomain(),
expectedMessage.getFrom ()[0].toString());
}
เราต้องแก้ไข MessageForwarder เพื่อเพิ่มฟังก์ชันนี้หรือไม่? ไม่จำเป็น—เราแค่สร้าง subclass ของ MessageForwarder และสร้างคลาสชื่อ AnonymousMessageForwarder เราสามารถใช้มันใน Test แทนได้
public void testAnonymous () throws Exception {
MessageForwarder forwarder = new AnonymousMessageForwarder();
forwarder.forwardMessage (makeFakeMessage());
assertEquals ("anon-members@" + forwarder.getDomain(),
expectedMessage.getFrom ()[0].toString());
}
จากนั้นเราก็สร้าง subclass (ดู Figure 8.1)
Figure 8.1 การสร้าง subclass MessageForwarder .
ตรงนี้เราได้ทำให้ method getFromAddress เป็น protected ใน MessageForwarder แทนที่จะเป็น private จากนั้นเรา override มันใน AnonymousMessageForwarder ในคลาสนั้น มันมีหน้าตาแบบนี้:
protected InternetAddress getFromAddress(Message message)
throws MessagingException {
String anonymousAddress = "anon-" + listAddress;
return new InternetAddress(anonymousAddress);
}
แล้วเราได้อะไร? เราก็แก้ปัญหาได้ แต่เราเพิ่มคลาสใหม่เข้าไปในระบบของเราเพื่อพฤติกรรมที่เรียบง่ายมาก มันสมเหตุสมผลไหมที่จะสร้าง subclass ของคลาส message-forwarding ทั้งคลาสเพียงเพื่อเปลี่ยนที่อยู่ "from"? ไม่ในระยะยาว แต่สิ่งที่ดีคือมันช่วยให้เราผ่าน Test ได้อย่างรวดเร็ว และเมื่อเรามี Test ที่ผ่านแล้ว เราสามารถใช้มันเพื่อให้แน่ใจว่าเรายังคงรักษาพฤติกรรมใหม่นี้ไว้เมื่อเราตัดสินใจว่าจะเปลี่ยนดีไซน์
public void testAnonymous () throws Exception {
MessageForwarder forwarder = new AnonymousMessageForwarder();
forwarder.forwardMessage (makeFakeMessage());
assertEquals ("anon-members@" + forwarder.getDomain(),
expectedMessage.getFrom ()[0].toString());
}
นั่นดูเหมือนจะง่ายเกินไป แล้วข้อเสียคืออะไร? คือว่า ถ้าเราใช้เทคนิคนี้ซ้ำๆ และไม่ใส่ใจกับประเด็นสำคัญบางอย่างของการออกแบบ มันจะเริ่มเสื่อมคุณภาพอย่างรวดเร็ว เพื่อให้เห็นว่าสิ่งใดเกิดขึ้นได้บ้าง ลองพิจารณาการเปลี่ยนแปลงอีกอย่างหนึ่ง เราต้องการส่งต่อข้อความไปยังผู้รับใน mailing list แต่เรายังต้องการส่งมันผ่าน blind carbon copy (bcc) ไปยังคนอื่นๆ ที่ไม่สามารถอยู่ใน mailing list ทางการได้ เราเรียกพวกเขาว่า off-list recipients
ดูเหมือนง่ายพอ เราสามารถสร้าง subclass ของ MessageForwarder อีกครั้งและ override method process ของมันเพื่อส่งข้อความไปยังปลายทางนั้น ดังใน Figure 8.2 .
Figure 8.2 การสร้าง subclass สำหรับความแตกต่างสองอย่าง
นั่นอาจใช้ได้ดียกเว้นสิ่งหนึ่ง ถ้าเราต้องการ MessageForwarder ที่ทำทั้งสองอย่าง: ส่งข้อความทั้งหมดไปยัง off-list recipients และทำการส่งต่อทั้งหมดแบบไม่เปิดเผยตัวตนล่ะ?
นี่คือหนึ่งในปัญหาใหญ่ของการใช้ inheritance อย่างกว้างขวาง ถ้าเราใส่ฟีเจอร์ลงใน subclass ที่แตกต่างกัน เราจะมีฟีเจอร์เหล่านั้นได้ทีละอันเท่านั้น
เราจะออกจากข้อจำกัดนี้ได้อย่างไร? วิธีหนึ่งคือหยุดก่อนที่จะเพิ่มฟีเจอร์ off-list recipients และ refactor เพื่อให้มันเข้าไปได้อย่างสะอาด โชคดีที่เรามี Test นั้นที่เราเขียนไว้ก่อนหน้านี้ เราสามารถใช้มันเพื่อยืนยันว่าเรายังคงรักษาพฤติกรรมไว้เมื่อเราเปลี่ยนไปใช้โครงสร้างอื่น
สำหรับฟีเจอร์ anonymous forwarding มีวิธีที่เราสามารถ implement ได้โดยไม่ต้องใช้ subclassing เราสามารถเลือกให้ anonymous forwarding เป็นตัวเลือกในการกำหนดค่าได้ วิธีหนึ่งคือการเปลี่ยน constructor ของคลาสเพื่อให้มันรับ collection ของ properties:
Properties configuration = new Properties();
configuration.setProperty("anonymous", "true");
MessageForwarder forwarder = new MessageForwarder(configuration);
เราสามารถทำให้ Test ของเราผ่านได้ไหมเมื่อเราทำแบบนั้น? ลองดู Test อีกครั้ง:
public void testAnonymous () throws Exception {
MessageForwarder forwarder = new AnonymousMessageForwarder();
forwarder.forwardMessage (makeFakeMessage());
assertEquals ("anon-members@" + forwarder.getDomain(),
expectedMessage.getFrom ()[0].toString());
}
ตอนนี้ Test นี้ผ่านอยู่ AnonymousMessageForwarder override method getFrom จาก MessageForwarder จะเกิดอะไรขึ้นถ้าเราเปลี่ยน method getFrom ใน MessageForwarder แบบนี้?
private InternetAddress getFromAddress(Message message)
throws MessagingException {
String fromAddress = getDefaultFrom();
if (configuration.getProperty("anonymous").equals("true")) {
fromAddress = "anon-members@" + domain;
}
else {
Address [] from = message.getFrom ();
if (from != null && from.length > 0) {
fromAddress = from [0].toString ();
}
}
return new InternetAddress (fromAddress);
}
ตอนนี้เรามี method getFrom ใน MessageFowarder ที่น่าจะจัดการได้ทั้งกรณี anonymous และกรณีปกติ เราสามารถยืนยันสิ่งนี้ได้โดยการ comment out การ override ของ getFrom ใน AnonymousMessageForwarder และดูว่า Test ผ่านหรือไม่:
public class AnonymousMessageForwarder extends MessageForwarder
{
/*
protected InternetAddress getFromAddress(Message message)
throws MessagingException {
String anonymousAddress = "anon-" + listAddress;
return new InternetAddress(anonymousAddress);
}
*/
}
ใช่แล้ว มันผ่าน
เราไม่ต้องการคลาส AnonymousMessageForwarder อีกต่อไปแล้ว ดังนั้นเราสามารถลบมันทิ้งได้ จากนั้นเราต้องหาทุกที่ที่เราสร้าง AnonymousMessageForwarder และแทนที่การเรียก constructor ด้วยการเรียก constructor ที่รับ properties collection
เราสามารถใช้ properties collection เพื่อเพิ่มฟีเจอร์ใหม่ได้เช่นกัน เราสามารถมี property ที่เปิดใช้งานฟีเจอร์ off-list recipient
เราเสร็จแล้วหรือยัง? ยังไม่จริง เราทำให้ method getFrom บน MessageForwarder ค่อนข้างรก แต่เพราะเรามี Test เราจึงสามารถทำ extract method เพื่อทำความสะอาดมันได้อย่างรวดเร็ว ตอนนี้มันมีหน้าตาแบบนี้:
private InternetAddress getFromAddress(Message message)
throws MessagingException {
String fromAddress = getDefaultFrom();
if (configuration.getProperty("anonymous").equals("true")) {
fromAddress = "anon-members@" + domain;
}
else {
Address [] from = message.getFrom ();
if (from != null && from.length > 0)
fromAddress = from [0].toString ();
}
return new InternetAddress (fromAddress);
}
หลังจาก refactoring บ้าง มันมีหน้าตาแบบนี้:
private InternetAddress getFromAddress(Message message)
throws MessagingException {
String fromAddress = getDefaultFrom();
if (configuration.getProperty("anonymous").equals("true")) {
from = getAnonymousFrom();
}
else {
from = getFrom(Message);
}
return new InternetAddress (from);
}
นั่นสะอาดขึ้นเล็กน้อย แต่ฟีเจอร์ anonymous mailing และ off-list recipient ถูกพับรวมเข้าไปใน MessageForwarder แล้วตอนนี้ แย่หรือไม่ในแง่ของ Single Responsibility Principle ( 246 ) ? มันอาจจะแย่ก็ได้ ขึ้นอยู่กับว่าโค้ดที่เกี่ยวข้องกับความรับผิดชอบหนึ่งๆ มีขนาดใหญ่แค่ไหนและมันพัวพันกับโค้ดส่วนอื่นมากแค่ไหน ในกรณีนี้ การตรวจสอบว่ารายการเป็น anonymous หรือไม่นั้นไม่ใช่เรื่องใหญ่ วิธีการแบบ property ทำให้เราก้าวต่อไปได้อย่างดี เราจะทำอย่างไรเมื่อมี property มากมายและโค้ดของ MessageForwarder เริ่มเต็มไปด้วยคำสั่งเงื่อนไข? สิ่งหนึ่งที่เราทำได้คือเริ่มใช้คลาสแทน properties collection จะเกิดอะไรขึ้นถ้าเราสร้างคลาสชื่อ Mailing-Configuration และให้มันเก็บ properties collection ไว้? (ดู Figure 8.3 .)
Figure 8.3 การมอบหมายให้ MailingConfiguration .
ดูดี แต่มันไม่มากเกินไปเหรอ? ดูเหมือน MailingConfiguration ก็แค่ทำสิ่งเดียวกันกับที่ properties collection ทำ
จะเกิดอะไรขึ้นถ้าเราตัดสินใจย้าย getFromAddress ไปยังคลาส MailingConfiguration ? คลาส MailingConfiguration สามารถรับ message และตัดสินใจว่าจะคืนที่อยู่ "from" ไหน ถ้าการกำหนดค่าถูกตั้งค่าให้ไม่เปิดเผยตัวตน มันจะคืนที่อยู่ "from" ของ anonymous mailing ถ้าไม่ มันก็สามารถเอาที่อยู่แรกจาก message และคืนมันได้ การออกแบบของเราจะเป็นดังที่แสดงใน Figure 8.4 สังเกตว่าเราไม่จำเป็นต้องมี method get และ set properties อีกต่อไป MailingConfiguration ตอนนี้รองรับฟังก์ชันระดับสูงกว่า
Figure 8.4 การย้ายพฤติกรรมไปยัง MailingConfiguration .
เรายังสามารถเริ่มเพิ่ม method อื่นๆ ให้กับ MailingConfiguration ได้อีกด้วย เช่น ถ้าเราต้องการ implement ฟีเจอร์ off-list recipients เราสามารถเพิ่ม method ชื่อ buildRecipientList ใน MailingConfiguration และให้ MessageForwarder ใช้มัน ดังที่แสดงใน Figure 8.5 .
Figure 8.5 การย้ายพฤติกรรมเพิ่มเติมไปยัง MailingConfiguration .
ด้วยการเปลี่ยนแปลงเหล่านี้ ชื่อของคลาสก็ไม่ดีเท่าที่ควรอีกต่อไป Configuration โดยทั่วไปแล้วเป็นสิ่งที่ค่อนข้างเฉื่อยชา คลาสนี้กำลังสร้างและปรับเปลี่ยนข้อมูลให้กับ MessageFowarders ตามคำขอของพวกมัน ถ้าไม่มีคลาสอื่นชื่อเดียวกันในระบบอยู่แล้ว ชื่อ MailingList อาจจะเหมาะสม MessageForwarders ขอให้ mailing list คำนวณที่อยู่จากและสร้างรายชื่อผู้รับ เราสามารถพูดได้ว่ามันเป็นความรับผิดชอบของ mailing list ที่จะกำหนดว่าข้อความจะถูกปรับเปลี่ยนอย่างไร Figure 8.6 แสดงการออกแบบของเราหลังจากเปลี่ยนชื่อ
Figure 8.6 MailingConfiguration เปลี่ยนชื่อเป็น MailingList .
มี refactoring ที่ทรงพลังมากมาย แต่ Rename Class ทรงพลังที่สุด มันเปลี่ยนวิธีที่ผู้คนมองโค้ดและช่วยให้พวกเขาสังเกตเห็นความเป็นไปได้ที่พวกเขาอาจไม่เคยคิดมาก่อน
Programming by Difference เป็นเทคนิคที่มีประโยชน์ มันช่วยให้เราสามารถเปลี่ยนแปลงได้อย่างรวดเร็ว และเราสามารถใช้ Test เพื่อย้ายไปสู่ดีไซน์ที่สะอาดขึ้น แต่การจะทำมันให้ดี เราต้องระวัง "gotchas" สองสามอย่าง หนึ่งในนั้นคือการละเมิด Liskov substitution principle (LSP) .
Liskov Substitution Principle (หลักการแทนที่ของ Liskov)
มีข้อผิดพลาดเล็กๆ น้อยๆ ที่ซ่อนอยู่ซึ่งเราสามารถก่อให้เกิดขึ้นได้เมื่อใช้ inheritance ลองพิจารณาโค้ดต่อไปนี้:
public class Rectangle
{
...
public Rectangle(int x, int y, int width, int height) { ... }
public void setWidth(int width) { ... }
public void setHeight(int height) { ... }
public int getArea() { ... }
}
เรามีคลาส Rectangle เราสามารถสร้าง subclass ชื่อ Square ได้ไหม?
public class Square extends Rectangle
{
...
public Square(int x, int y, int width) { ... }
...
}
Square สืบทอด method setWidth และ setHeight ของ Rectangle พื้นที่ควรเป็นเท่าไหร่เมื่อเราเรียกใช้โค้ดนี้?
Rectangle r = new Square();
r.setWidth(3);
r.setHeight(4);
ถ้าพื้นที่คือ 12 Square ก็ไม่ใช่สี่เหลี่ยมจัตุรัสจริงๆ ใช่ไหม? เราสามารถ override setWidth และ setHeight เพื่อให้ Square ยังคงเป็น "สี่เหลี่ยมจัตุรัส" ได้ เราสามารถให้ setWidth และ setHeight ทั้งคู่แก้ไขตัวแปร width ใน squares ได้ แต่นั่นอาจนำไปสู่ผลลัพธ์ที่ขัดกับความเข้าใจทั่วไป ใครก็ตามที่คาดหวังว่า rectangles ทั้งหมดจะมีพื้นที่ 12 เมื่อความกว้างถูกตั้งเป็น 3 และความสูงถูกตั้งเป็น 4 จะต้องประหลาดใจ พวกเขาจะได้ 16 แทน
นี่คือตัวอย่างคลาสสิกของการละเมิด Liskov Substitution Principle (LSP) ออบเจกต์ของ subclass ควรสามารถใช้แทนที่ออบเจกต์ของ superclass ได้ทั่วทั้งโค้ดของเรา ถ้าไม่ เราอาจมีข้อผิดพลาดเงียบๆ ในโค้ดของเรา
LSP หมายความว่า client ของคลาสควรจะสามารถใช้ออบเจกต์ของ subclass ได้โดยไม่ต้องรู้ว่ามันเป็นออบเจกต์ของ subclass ไม่มีวิธีเชิงกลไกใดที่จะหลีกเลี่ยงการละเมิด LSP ได้อย่างสมบูรณ์ การที่คลาสจะสอดคล้องกับ LSP หรือไม่นั้นขึ้นอยู่กับ client ที่มันมีและสิ่งที่ client คาดหวัง อย่างไรก็ตาม มีกฎง่ายๆ ที่ช่วยได้:
1. เมื่อใดก็ตามที่เป็นไปได้ หลีกเลี่ยงการ override concrete methods
2. ถ้าคุณทำ ดูว่าคุณสามารถเรียก method ที่คุณกำลัง override ภายใน method ที่ override ได้หรือไม่
เดี๋ยวก่อน เราไม่ได้ทำสิ่งเหล่านั้นใน MessageForwarder จริงๆ แล้วเราทำตรงกันข้าม เรา override concrete method ใน subclass ( AnonymousMessage-Forwarder ) แล้วมันสำคัญยังไง?
ประเด็นคือ: เมื่อเรา override concrete methods อย่างที่เราทำเมื่อเรา override getFromAddress ของ MessageForwarder ใน AnonymousMessageForwarder เราอาจกำลังเปลี่ยนความหมายของโค้ดบางส่วนที่ใช้ MessageFowarders อยู่ ถ้ามีการอ้างอิงถึง MessageForwarder กระจายอยู่ทั่วแอปพลิเคชันของเราและเราตั้งค่าหนึ่งในนั้นให้เป็น AnonymousMessageForwarder คนที่ใช้มันอาจคิดว่ามันเป็นแค่ MessageFowarder ทั่วไปและมันจะดึงที่อยู่ "from" จากข้อความที่กำลังประมวลผลและใช้มันเมื่อประมวลผลข้อความ มันจะมีความสำคัญหรือไม่สำหรับคนที่ใช้คลาสนี้ว่ามันทำแบบนั้นหรือใช้ที่อยู่พิเศษอื่นเป็นที่อยู่ "from"? นั่นขึ้นอยู่กับแอปพลิเคชัน โดยทั่วไป โค้ดจะสับสนเมื่อเรา override concrete methods บ่อยเกินไป มีคนสังเกตเห็นการอ้างอิง MessageForwarder ในโค้ด ดูที่คลาส MessageFowarder และคิดว่าโค้ดที่มันมีสำหรับ getFromAddress ถูกเรียกใช้ พวกเขาอาจไม่รู้เลยว่าการอ้างอิงนั้นชี้ไปที่ AnonymousMessageForwarder และ method getFromAddress ของมันต่างหากที่ถูกใช้ ถ้าเราต้องการคง inheritance ไว้จริงๆ เราสามารถทำให้ MessageForwarder เป็น abstract, ให้มันมี abstract method สำหรับ getFromAddress และให้ subclass จัดหา concrete bodies Figure 8.7 แสดงให้เห็นว่าดีไซน์จะเป็นอย่างไรหลังจากการเปลี่ยนแปลงเหล่านี้
Figure 8.7 ลำดับชั้นที่ถูกทำให้เป็นมาตรฐาน (Normalized hierarchy)
ผมเรียกโครงสร้างแบบนี้ว่า normalized hierarchy ใน normalized hierarchy ไม่มีคลาสใดที่มีการ implement method มากกว่าหนึ่ง implementation กล่าวคือ ไม่มีคลาสใดที่มี method ที่ override concrete method ที่มันสืบทอดมาจาก superclass เมื่อคุณถามคำถาม "คลาสนี้ทำ X ได้อย่างไร?" คุณสามารถตอบได้โดยไปที่คลาส X และดู method นั้น ไม่ method นั้นอยู่ที่นั่น หรือมันเป็น abstract และถูก implement ใน subclass ใด subclass หนึ่ง ใน normalized hierarchy คุณไม่ต้องกังวลว่า subclass จะ override พฤติกรรมที่พวกมันสืบทอดมาจาก superclass
มันคุ้มที่จะทำแบบนี้ตลอดเวลาหรือไม่? การ override concrete methods บางครั้งเป็นครั้งคราวไม่ได้สร้างความเสียหาย ตราบใดที่มันไม่ก่อให้เกิดการละเมิด Liskov substitution principle อย่างไรก็ตาม เป็นการดีที่จะคิดว่าคลาสต่างๆ อยู่ห่างจากรูปแบบ normalized แค่ไหนเป็นครั้งคราว และบางครั้งก็เคลื่อนไปทางนั้นเมื่อเราเตรียมที่จะแยกความรับผิดชอบออกจากกัน
Programming by Difference ช่วยให้เรานำรูปแบบที่แตกต่างกันเข้าสู่ระบบได้อย่างรวดเร็ว เมื่อเราทำเช่นนั้น เราสามารถใช้ Test ของเราเพื่อยึดพฤติกรรมใหม่และเคลื่อนไปสู่โครงสร้างที่เหมาะสมกว่าเมื่อเราต้องการ Test ทำให้การเคลื่อนย้ายนี้รวดเร็วมาก
Summary (สรุป)
คุณสามารถใช้เทคนิคในบทนี้เพื่อเพิ่มฟีเจอร์ให้กับโค้ดใดๆ ที่คุณสามารถทำให้อยู่ภายใต้ Test ได้ วรรณกรรมเกี่ยวกับ test-driven development เติบโตขึ้นอย่างมากในช่วงไม่กี่ปีที่ผ่านมา โดยเฉพาะอย่างยิ่ง ผมขอแนะนำหนังสือของ Kent Beck Test-Driven Development by Example (Addison-Wesley, 2002) และของ Dave Astel Test-Driven Development : A Practical Guide (Prentice Hall Professional Technical Reference, 2003)