ความคลุมเครือ (Obscurity) เป็นหนึ่งในสองสาเหตุหลักของความซับซ้อนตามที่กล่าวไว้ใน Section 2.3 ความคลุมเครือเกิดขึ้นเมื่อข้อมูลสำคัญเกี่ยวกับระบบไม่ชัดเจนสำหรับนักพัฒนาใหม่ วิธีแก้ปัญหาความคลุมเครือคือการเขียนโค้ดให้อ่านแล้วเข้าใจได้ทันที บทนี้จะพูดถึงปัจจัยต่างๆ ที่ทำให้โค้ดอ่านแล้วเข้าใจง่ายหรือยากขึ้น
ถ้าโค้ดอ่านแล้วเข้าใจได้ทันที (obvious) หมายความว่าคนอ่านสามารถอ่านโค้ดได้อย่างรวดเร็วโดยไม่ต้องใช้ความคิดมาก และการเดาครั้งแรกเกี่ยวกับพฤติกรรมหรือความหมายของโค้ดจะถูกต้อง ถ้าโค้ดอ่านแล้วเข้าใจได้ทันที ผู้อ่านไม่ต้องใช้เวลาหรือความพยายามมากเพื่อรวบรวมข้อมูลทั้งหมดที่ต้องการในการทำงานกับโค้ด ถ้าโค้ดไม่ชัดเจน ผู้อ่านจะต้องใช้เวลาและพลังงานอย่างมากในการทำความเข้าใจ ซึ่งไม่เพียงแต่ลดประสิทธิภาพ แต่ยังเพิ่มโอกาสในการเข้าใจผิดและเกิดบั๊กอีกด้วย โค้ดที่อ่านแล้วเข้าใจได้ทันทีต้องการ comment น้อยกว่าโค้ดที่อ่านแล้วไม่ชัดเจน
"ความชัดเจน" อยู่ในมุมมองของผู้อ่าน: การสังเกตว่าโค้ดของคนอื่นไม่ชัดเจนนั้นทำได้ง่ายกว่าการเห็นปัญหาของโค้ดตัวเอง ดังนั้นวิธีที่ดีที่สุดในการวัดความชัดเจนของโค้ดคือการใช้ code review ถ้ามีคนอ่านโค้ดของคุณแล้วบอกว่ามันไม่ชัดเจน นั่นหมายความว่ามันไม่ชัดเจน ไม่ว่าคุณจะรู้สึกว่ามันชัดเจนแค่ไหนก็ตาม การพยายามทำความเข้าใจว่าอะไรที่ทำให้โค้ดไม่ชัดเจนจะช่วยให้คุณเรียนรู้วิธีเขียนโค้ดที่ดีขึ้นในอนาคต
18.1 สิ่งที่ทำให้โค้ดอ่านแล้วเข้าใจได้ง่ายขึ้น (Things that make code more obvious)
สองเทคนิคที่สำคัญที่สุดในการทำให้โค้ดอ่านแล้วเข้าใจได้ง่ายได้ถูกกล่าวถึงไปแล้วในบทก่อนหน้านี้ เทคนิคแรกคือการเลือกชื่อที่ดี ( Chapter 14 ) ชื่อที่แม่นยำ และมีความหมายช่วยอธิบายพฤติกรรมของโค้ดและลดความจำเป็นในการใช้ documentation ถ้าชื่อกำกวมหรือคลุมเครือ ผู้อ่านจะต้องอ่านโค้ดทั้งหมดเพื่อเดาความหมายของสิ่งที่ถูกตั้งชื่อ ซึ่งเสียเวลาและมีโอกาสผิดพลาดสูง เทคนิคที่สองคือความสม่ำเสมอ ( Chapter 17 ) ถ้าสิ่งที่คล้ายกันถูกทำในรูปแบบเดียวกันเสมอ ผู้อ่านจะสามารถจดจำ pattern ที่เคยเห็นมาก่อนและสรุปผล (ที่ปลอดภัย) ได้ทันทีโดยไม่ต้องวิเคราะห์โค้ดอย่างละเอียด
ต่อไปนี้คือเทคนิคทั่วไปอื่นๆ ที่ช่วยให้โค้ดอ่านแล้วเข้าใจได้ง่ายขึ้น:
การใช้ช่องว่างอย่างชาญฉลาด (Judicious use of white space.) การจัดรูปแบบโค้ดส่งผลต่อความง่ายในการทำความเข้าใจ ลองดูตัวอย่าง documentation ของ parameter ต่อไปนี้ ซึ่งช่องว่างถูกบีบออกจนหมด:
/**
* ...
* @param numThreads The number of threads that this manager should
* spin up in order to manage ongoing connections. The MessageManager
* spins up at least one thread for every open connection, so this
* should be at least equal to the number of connections you expect
* to be open at once. This should be a multiple of that number if
* you expect to send a lot of messages in a short amount of time.
* @param handler Used as a callback in order to handle incoming
* messages on this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
เป็นการยากที่จะเห็นว่า documentation ของ parameter หนึ่งสิ้นสุดลงและอีกตัวหนึ่งเริ่มต้นที่ไหน ไม่ชัดเจนด้วยซ้ำว่ามีทั้งหมดกี่ parameter หรือชื่อของมันคืออะไร ถ้าเพิ่มช่องว่างเล็กน้อย โครงสร้างก็จะชัดเจนขึ้นทันทีและ documentation จะอ่านง่ายขึ้น:
/**
* @param numThreads
* The number of threads that this manager should spin up in
* order to manage ongoing connections. The MessageManager spins
* up at least one thread for every open connection, so this
* should be at least equal to the number of connections you
* expect to be open at once. This should be a multiple of that
* number if you expect to send a lot of messages in a short
* amount of time.
* @param handler
* Used as a callback in order to handle incoming messages on
* this MessageManager's open connections. See
* {@code MessageHandler} and {@code handleMessage} for details.
*/
การขึ้นบรรทัดใหม่ (blank line) ก็มีประโยชน์ในการแยกบล็อกโค้ดหลักภายใน method เช่นในตัวอย่างต่อไปนี้:
void* Buffer::allocAux(size_t numBytes)
{
// Round up the length to a multiple of 8 bytes, to ensure alignment.
uint32_t numBytes32 = (downCast<uint32_t>(numBytes) + 7) & ~0x7;
assert(numBytes32 != 0);
// If there is enough memory at firstAvailable, use that. Work down
// from the top, because this memory is guaranteed to be aligned
// (memory at the bottom may have been used for variable-size chunks).
if (availableLength >= numBytes32) {
availableLength -= numBytes32;
return firstAvailable + availableLength;
}
// Next, see if there is extra space at the end of the last chunk.
if (extraAppendBytes >= numBytes32) {
extraAppendBytes -= numBytes32;
return lastChunk->data + lastChunk->length + extraAppendBytes;
}
// Must create a new space allocation; allocate space within it.
uint32_t allocatedLength;
firstAvailable = getNewAllocation(numBytes32, &allocatedLength);
availableLength = allocatedLength numBytes32;
return firstAvailable + availableLength;
}
วิธีนี้ใช้ได้ดีโดยเฉพาะถ้าบรรทัดแรกหลังจาก blank line แต่ละบรรทัดเป็น comment ที่อธิบายบล็อกโค้ดถัดไป: blank lines จะทำให้ comment เห็นได้ชัดเจนขึ้น
ช่องว่างภายใน statement ก็ช่วยให้โครงสร้างของ statement ชัดเจนขึ้น ลองเปรียบเทียบสอง statement ต่อไปนี้ อันหนึ่งมีช่องว่าง อีกอันไม่มี:
for(int pass=1;pass>=0&&!empty;pass--) {
for (int pass = 1; pass >= 0 && !empty; pass--) {
Comments. บางครั้งก็ไม่สามารถหลีกเลี่ยงการเขียนโค้ดที่อ่านแล้วไม่ชัดเจนได้ ในกรณีเช่นนี้ สิ่งสำคัญคือต้องใช้ comment เพื่อชดเชยโดยให้ข้อมูลที่ขาดหายไป การทำเช่นนี้ให้ดี คุณต้องวางตัวเองในตำแหน่งของผู้อ่านและคิดว่าอะไรน่าจะทำให้พวกเขาสับสน และข้อมูลใดที่จะช่วยเคลียร์ความสับสนนั้น หัวข้อถัดไปจะแสดงตัวอย่างสองสามตัวอย่าง
18.2 สิ่งที่ทำให้โค้ดอ่านแล้วเข้าใจยากขึ้น (Things that make code less obvious)
มีหลายสิ่งที่ทำให้โค้ดอ่านแล้วไม่ชัดเจน หัวข้อนี้จะยกตัวอย่างบางส่วน บางอย่างเช่น event-driven programming ก็มีประโยชน์ในบางสถานการณ์ ดังนั้นคุณอาจจำเป็นต้องใช้มันอยู่ดี ในกรณีเช่นนี้ documentation เพิ่มเติมจะช่วยลดความสับสนของผู้อ่านได้
Event-driven programming. ใน event-driven programming แอปพลิเคชันจะตอบสนองต่อเหตุการณ์ภายนอก เช่น การมาถึงของ network packet หรือการกดปุ่มเมาส์ โมดูลหนึ่งมีหน้าที่รายงานเหตุการณ์ที่เข้ามา ส่วนอื่นๆ ของแอปพลิเคชันจะลงทะเบียนความสนใจในเหตุการณ์บางอย่างโดยขอให้ event module เรียก function หรือ method ที่กำหนดเมื่อเหตุการณ์นั้นเกิดขึ้น
Event-driven programming ทำให้การตาม flow of control เป็นเรื่องยาก ฟังก์ชัน event handler ไม่ได้ถูกเรียกโดยตรง แต่ถูกเรียกโดยอ้อมผ่าน event module ซึ่งมักจะใช้ function pointer หรือ interface ถึงแม้คุณจะหาจุดที่ถูกเรียกใน event module ได้ ก็ยังไม่สามารถบอกได้ว่าฟังก์ชันใดจะถูกเรียก เพราะขึ้นอยู่กับว่า handler ใดถูกลงทะเบียนไว้ตอน runtime ด้วยเหตุนี้ การใช้เหตุผลเกี่ยวกับ event-driven code หรือการทำให้แน่ใจว่ามันทำงานได้ถูกต้องจึงเป็นเรื่องยาก
เพื่อชดเชยความคลุมเครือนี้ ให้ใช้ interface comment สำหรับแต่ละ handler function เพื่อระบุว่ามันถูกเรียกเมื่อใด ดังตัวอย่างนี้:
/**
* This method is invoked in the dispatch thread by a transport if a
* transport-level error prevents an RPC from completing.
*/
void
Transport::RpcNotifier::failed() {
...
}
Red Flag: Nonobvious Code (ธงแดง: โค้ดที่อ่านแล้วไม่ชัดเจน)
ถ้าความหมายและพฤติกรรมของโค้ดไม่สามารถเข้าใจได้ด้วยการอ่านอย่างรวดเร็ว นั่นคือธงแดง บ่อยครั้งที่หมายความว่ามีข้อมูลสำคัญที่ไม่ชัดเจนในทันทีสำหรับคนที่อ่านโค้ด
Generic containers (Container ทั่วไป). หลายภาษาให้ generic class สำหรับจัดกลุ่ม items ตั้งแต่สองตัวขึ้นไปเป็น object เดียว เช่น Pair ใน Java หรือ std::pair ใน C++ คลาสเหล่านี้ดูน่าสนใจเพราะทำให้การส่งผ่าน object หลายตัวด้วยตัวแปรเดียวทำได้ง่าย หนึ่งในการใช้งานที่พบบ่อยที่สุดคือการคืนค่าหลายค่าจาก method ดังตัวอย่าง Java นี้:
return new Pair<Integer, Boolean>(currentTerm, false);
แต่น่าเสียดายที่ generic container ทำให้โค้ดอ่านแล้วไม่ชัดเจนเพราะ element ที่ถูกจัดกลุ่มมีชื่อที่ไม่เฉพาะเจาะจง ซึ่งทำให้ความหมายคลุมเครือ ในตัวอย่างข้างต้น ผู้เรียกต้องอ้างอิงค่าที่คืนมาสองค่าด้วย result.getKey() และ result.getValue() ซึ่งไม่ได้ให้คำใบ้เกี่ยวกับความหมายที่แท้จริงของค่าเหล่านั้นเลย
ดังนั้น จึงควรหลีกเลี่ยงการใช้ generic container ถ้าคุณต้องการ container ให้กำหนด class หรือ structure ใหม่ที่เฉพาะเจาะจงกับการใช้งานนั้นๆ คุณจะได้ใช้ชื่อที่มีความหมายสำหรับ element ต่างๆ และสามารถให้ documentation เพิ่มเติมใน declaration ซึ่งทำไม่ได้กับ generic container
ตัวอย่างนี้แสดงให้เห็นกฎทั่วไป: ซอฟต์แวร์ควรถูกออกแบบให้อ่านง่าย ไม่ใช่เขียนง่าย (software should be designed for ease of reading, not ease of writing). Generic container สะดวกสำหรับคนเขียนโค้ด แต่สร้างความสับสนให้กับผู้อ่านทุกคนที่ตามมา เป็นการดีกว่าที่คนเขียนโค้ดจะใช้เวลาพิเศษอีกสักเล็กน้อยในการกำหนดโครงสร้าง container ที่เฉพาะเจาะจง เพื่อให้โค้ดที่ได้อ่านแล้วเข้าใจได้ง่ายขึ้น
ชนิดข้อมูลที่แตกต่างกันระหว่าง declaration และ allocation (Different types for declaration and allocation.) ลองพิจารณาตัวอย่าง Java ต่อไปนี้:
private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();
ตัวแปรถูกประกาศเป็น List แต่ค่าจริงเป็น ArrayList โค้ดนี้ใช้ได้ตามกฎหมายเพราะ List เป็น superclass ของ ArrayList แต่มันอาจทำให้ผู้อ่านที่เห็นเฉพาะ declaration แต่ไม่เห็น allocation เข้าใจผิดได้ ชนิดข้อมูลจริงอาจส่งผลต่อการใช้งานตัวแปร ( ArrayLists มีคุณสมบัติด้าน performance และ thread-safety ที่แตกต่างจาก subclass อื่นๆ ของ List ) ดังนั้นจึงควรให้ declaration ตรงกับ allocation
โค้ดที่ขัดกับความคาดหวังของผู้อ่าน (Code that violates reader expectations.) ลองพิจารณาโค้ดต่อไปนี้ซึ่งเป็น main program ของ Java application:
public static void main(String[] args) {
...
new RaftClient(myAddress, serverAddresses);
}
แอปพลิเคชันส่วนใหญ่มักจะจบการทำงานเมื่อ main program return ดังนั้นผู้อ่านมักจะเข้าใจ ว่ามันจะเกิดขึ้นที่นี่ แต่ความเป็นจริงไม่ใช่แบบนั้น constructor ของ RaftClient สร้าง threads เพิ่มเติม ซึ่งยังคงทำงานต่อไปแม้ว่า main thread ของแอปพลิเคชันจะสิ้นสุดลง พฤติกรรมนี้ควรถูกบันทึกไว้ใน interface comment ของ RaftClient constructor แต่เนื่องจากพฤติกรรมนี้ไม่ชัดเจนมากพอ ก็ควรใส่ comment สั้นๆ ไว้ที่ท้าย main เช่นกัน comment ควรระบุว่าแอปพลิเคชันจะยังคงทำงานต่อไปใน threads อื่นๆ โค้ดจะชัดเจนที่สุดถ้ามันเป็นไปตาม conventions ที่ผู้อ่านคาดหวัง ถ้าไม่เป็นไปตามนั้น ก็สำคัญที่จะต้อง document พฤติกรรมเพื่อไม่ให้ผู้อ่านสับสน
18.3 บทสรุป (Conclusion)
อีกวิธีหนึ่งในการคิดเกี่ยวกับความชัดเจนคือในแง่ของข้อมูล ถ้าโค้ดไม่ชัดเจน นั่นมักหมายความว่าผู้อ่านขาดข้อมูลสำคัญเกี่ยวกับโค้ดนั้น ในตัวอย่าง RaftClient ผู้อ่านอาจไม่รู้ว่า constructor ของ RaftClient สร้าง threads ใหม่ขึ้นมา ในตัวอย่าง Pair ผู้อ่านอาจไม่รู้ว่า result.getKey() คืนค่าหมายเลขของ current term
การทำให้โค้ดชัดเจน คุณต้องมั่นใจว่าผู้อ่านมีข้อมูลที่จำเป็นในการทำความเข้าใจโค้ดอยู่เสมอ ทำได้สามวิธี วิธีที่ดีที่สุดคือลดปริมาณข้อมูลที่ต้องการ โดยใช้เทคนิคการออกแบบเช่น abstraction และการกำจัด special cases วิธีที่สองคือใช้ประโยชน์จากข้อมูลที่ผู้อ่านมีอยู่แล้วจากบริบทอื่น (เช่น การทำตาม conventions และความคาดหวัง) เพื่อให้ผู้อ่านไม่ต้องเรียนรู้ข้อมูลใหม่สำหรับโค้ดของคุณ วิธีที่สามคือการนำเสนอข้อมูลสำคัญให้ผู้อ่านเห็นในโค้ด โดยใช้เทคนิคเช่นการตั้งชื่อที่ดีและการใช้ comment อย่างมีกลยุทธ์