Code Should be Obvious (โค้ดควรจะชัดเจน)

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

ถ้าโค้ดชัดเจน มันหมายความว่าใครสักคนสามารถอ่านโค้ดได้อย่างรวดเร็ว โดยไม่ต้องคิดมาก และการเดาครั้งแรกของพวกเขาเกี่ยวกับพฤติกรรมหรือความหมายของโค้ดจะถูกต้อง ถ้าโค้ดชัดเจน ผู้อ่านไม่จำเป็นต้องใช้เวลาหรือความพยายามมากในการรวบรวมข้อมูลทั้งหมดที่พวกเขาต้องการเพื่อทำงานกับโค้ด ถ้าโค้ดไม่ชัดเจน ผู้อ่านต้องใช้เวลาและพลังงานจำนวนมากเพื่อทำความเข้าใจ สิ่งนี้ไม่เพียงแต่ลดประสิทธิภาพของพวกเขาเท่านั้น แต่ยังเพิ่มโอกาสของการเข้าใจผิดและバグ โค้ดที่ชัดเจนต้องการ Comment น้อยกว่าโค้ดที่ไม่ชัดเจน

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

18.1 Things that make code more obvious (สิ่งต่างๆ ที่ทำให้โค้ดชัดเจนมากขึ้น)

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

นี่คือเทคนิคอื่นๆ เพื่อจุดประสงค์ทั่วไปสำหรับการทำให้โค้ดชัดเจนมากขึ้น:

Judicious use of white space (การใช้ว่างเว้นอย่างรอบคอบ) วิธีการจัดรูปแบบโค้ดสามารถส่งผลต่อความง่ายในการเข้าใจ พิจารณาเอกสารพารามิเตอร์ต่อไปนี้ ซึ่งมีว่างเว้นหายไป:

/**
 *  ...
 *  @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.
 */

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

/**
 *  @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.
 */

บรรทัดว่างก็มีประโยชน์ในการแยกบล็อกรหัสหลัก ภายในเมธอด เช่นเดียวกับในตัวอย่างต่อไปนี้:

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;
}

วิธีการนี้ทำงานได้ดีเป็นพิเศษถ้าบรรทัดแรกหลังจากบรรทัดว่างแต่ละบรรทัดเป็น Comment ที่อธิบายบล็อกโค้ดถัดไป: บรรทัดว่างทำให้ Comment มองเห็นได้มากขึ้น

ว่างเว้นภายในคำสั่งช่วยชี้แจงโครงสร้างของคำสั่ง เปรียบเทียบกับสองคำสั่งต่อไปนี้ อันหนึ่งมีว่างเว้นและอีกอันหนึ่งไม่มี:

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 นั้นมีประโยชน์ในสถานการณ์บางอย่าง ดังนั้นคุณอาจจะใช้มันอยู่ดี เมื่อสิ่งนี้เกิดขึ้น เอกสารเพิ่มเติมสามารถช่วยลดความสับสนของผู้อ่าน

Event-driven programming (การเขียนโปรแกรมแบบอีเวนต์ขับเคลื่อน) ในการเขียนโปรแกรมแบบ Event-Driven โปรแกรมจะตอบสนองต่อการเกิดขึ้นภายนอก เช่น การมาถึงของแพคเก็ตเครือข่ายหรือการกดปุ่มเมาส์ Module หนึ่งรับผิดชอบในการรายงานเหตุการณ์ที่เข้ามา ส่วนอื่นๆ ของแอปพลิเคชันลงทะเบียนความสนใจในเหตุการณ์บางอย่างโดยขอให้ Module อีเวนต์เรียกใช้ฟังก์ชันหรือเมธอดที่กำหนดเมื่อเหตุการณ์เหล่านั้นเกิดขึ้น

การเขียนโปรแกรมแบบ Event-Driven ทำให้ยากต่อการติดตามการไหลของการควบคุม ฟังก์ชน Handler อีเวนต์ไม่เคยถูกเรียกโดยตรง พวกเขาถูกเรียกโดยอ้อมโดย Event Module โดยทั่วไปใช้ Function Pointer หรือ Interface แม้ว่าคุณหาจุดเรียกใช้ใน Event Module ได้ก็ตาม ยังคงเป็นไปไม่ได้ที่จะบอกว่าฟังก์ชนใดที่จะถูกเรียกใช้: สิ่งนี้จะขึ้นอยู่กับ Handler ใดที่ลงทะเบียนไว้ในเวลาทำงาน เนื่องจากเหตุนี้ จึงยากที่จะให้เหตุผลเกี่ยวกับโค้ด Event-Driven หรือโน้มน้าวตัวเองว่ามันใช้งานได้

เพื่อชดเชยความไม่ชัดเจนนี้ ให้ใช้ Interface Comment สำหรับฟังก์ชน Handler แต่ละตัวเพื่อระบุว่าเมื่อใดที่มันถูกเรียกใช้ เช่นในตัวอย่างนี้:

/**
 * 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() {
        ...
}

img Red Flag: Nonobvious Code (ธงแดง: โค้ดที่ไม่ชัดเจน) img

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

Generic containers (ภาชนะทั่วไป) ภาษาโปรแกรมหลายภาษามีคลาส Generic สำหรับการจัดกลุ่มสองรายการหรือมากกว่านั้นเป็นวัตถุเดียว เช่น Pair ใน Java หรือ std::pair ใน C++ คลาสเหล่านี้มีความดึงดูดใจเพราะทำให้ง่ายที่จะส่งวัตถุหลายตัวด้วยตัวแปรเดียว หนึ่งในการใช้งานที่พบบ่อยที่สุดคือการส่งคืนค่าหลายตัวจากเมธอด เช่นในตัวอย่าง Java นี้:

return new Pair<Integer, Boolean>(currentTerm, false);

น่าเสียดายที่ Generic Container ส่งผลให้โค้ดไม่ชัดเจนเพราะองค์ประกอบที่จัดกลุ่มมีชื่อทั่วไปที่ปกปิดความหมาย ในตัวอย่างข้างต้น ผู้เรียกต้องอ้างอิงค่าที่ส่งคืนสองค่าด้วย result.getKey() และ result.getValue() ซึ่งไม่มีเบาะแสเกี่ยวกับความหมายจริงของค่า

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

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

Different types for declaration and allocation (ประเภทต่างกันสำหรับการประกาศและการจัดสรร) พิจารณาตัวอย่าง Java ต่อไปนี้:

private List<Message> incomingMessageList;
...
incomingMessageList = new ArrayList<Message>();

ตัวแปรถูกประกาศเป็น List แต่ค่าจริงคือ ArrayList โค้ดนี้ถูกต้องเนื่องจาก List เป็น Superclass ของ ArrayList แต่มันสามารถทำให้ผู้อ่านข้อมูลการประกาศแต่ไม่ได้เห็นการจัดสรรจริง ประเภทจริงอาจส่งผลต่อวิธีการใช้ตัวแปร (ArrayLists มี Performance และ Thread-Safety Properties ต่างกันจากคลาสย่อยอื่นๆ ของ List) ดังนั้นจึงดีกว่าที่จะจับคู่การประกาศกับการจัดสรร

Code that violates reader expectations (โค้ดที่ละเมิดคาดหวังของผู้อ่าน) พิจารณาโค้ดต่อไปนี้ ซึ่งเป็นโปรแกรมหลักสำหรับแอปพลิเคชัน Java

public static void main(String[] args) {
        ...
        new RaftClient(myAddress, serverAddresses);
}

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

18.3 Conclusion (บทสรุป)

อีกวิธีหนึ่งในการคิดเกี่ยวกับความชัดเจนคือในแง่ของข้อมูล ถ้าโค้ดไม่ชัดเจน โดยปกติหมายความว่ามีข้อมูลที่สำคัญเกี่ยวกับโค้ดที่ผู้อ่านไม่มี: ในตัวอย่าง RaftClient ผู้อ่านอาจไม่ทราบว่า Constructor ของ RaftClient สร้าง Thread ใหม่ ในตัวอย่าง Pair ผู้อ่านอาจไม่ทราบว่า result.getKey() ส่งคืนหมายเลขของเทอมปัจจุบัน

เพื่อให้โค้ดชัดเจน คุณต้องให้มั่นใจว่าผู้อ่านมีข้อมูลที่พวกเขาต้องการเพื่อทำความเข้าใจ คุณสามารถทำได้ด้วยสามวิธี วิธีที่ดีที่สุดคือลดจำนวนข้อมูลที่ต้องการ โดยใช้เทคนิคการออกแบบเช่น Abstraction และการขจัดกรณีพิเศษ ประการที่สอง คุณสามารถใช้ประโยชน์จากข้อมูลที่ผู้อ่านได้เรียนรู้ไปแล้วในบริบทอื่นๆ (เช่น โดยการปฏิบัติตามข้อตกลงและการปฏิบัติตามคาดหวัง) เพื่อให้ผู้อ่านไม่ต้องเรียนรู้ข้อมูลใหม่สำหรับโค้ดของคุณ ประการที่สาม คุณสามารถนำเสนอข้อมูลที่สำคัญให้พวกเขาในโค้ด โดยใช้เทคนิคเช่นชื่อที่ดีและ Comment ที่เลือกตำแหน่งด้วยพิจารณา