Code quality without familiarity

March 07, 2021

หลายๆ ครั้งผมพบว่าโปรแกรมเมอร์มักจะตัดสินคุณภาพของโค้ดจากปัจจัยเพียงแค่ว่า Pattern ที่เขาใช้ตรงกับที่เราใช้มั้ย

เช่น ถ้าสมมติเราทำโค้ด MVC อาจจะมีทีมนึงกำหนดกฎไว้แบบนี้

  • Controller ห้ามมี Logic
  • ใน View ให้เข้าถึง Model แล้วถ้าอยากแสดงอะไรพิเศษให้เพิ่มใน Model
  • Model เป็นที่รวม Logic ส่วนมาก รวมถึงการเข้าฐานข้อมูล
  • ให้ใช้ Dependency Injection ในการทำให้ Unit test ได้

ทีนี้เวลาเราไปเจอโค้ดของอีกทีมที่ใช้ Pattern อีกอย่างกับที่เราคุ้นชิน เช่น

  • หากต้องการแสดงอะไรพิเศษให้สร้าง Presenter หรือ ViewModel ขึ้นมา
  • Controller มี Logic ในการตรวจสอบ Request ได้
  • ให้ใช้ Stub ในการทำให้ Unit test ได้

เราก็อาจจะคิดว่าโค้ดนั้นห่วยเพราะมันไม่ถูกต้องตามที่เราเรียนมาหรือคุ้นชินมา

ผมมองว่าการใช้ความคุ้นชินแบบนี้เป็นตัวชี้วัดว่าโค้ดมีคุณภาพมั้ยที่ไม่ดีมากๆ โค้ดหลายตัวมีคุณภาพที่ดีโดยไม่จำเป็นต้องใช้ Design Pattern ที่เรารู้จัก ตัวเราเองก็ไม่สามารถไปอ่านหรือทำความเข้าใจทุกๆ Design Pattern บนโลกนี้ได้

ผมมีโอกาสอ่านโค้ดของ Candidate ทำงานกับ Legacy code มาเยอะ มีโอกาสเขียนโปรแกรมมาหลากหลายภาษา แต่ละภาษาแต่ละโค้ดเองก็มีทั้งสไตล์, Framework, Design decision, Best practices ที่แตกต่างกัน

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

  • วันที่ผมเปลี่ยนจาก Angular มาเป็น React งี้ ผมไม่สามารถบอกได้ละว่าโค้ดที่ดีต้องมี Dependency injection ใน React มันไม่เหมาะเอามากๆ
  • วันที่ผมใช้เทคโนโลยี Web API กับ MVC เพื่อเขียน C# JSON API Application ก็มีความแตกต่างมากในแง่ของ Entity ที่ใช้ในระดับ API ผูกกับ Object ขนาดไหน อันนึงออกแบบบนฐานของ Coupling Entity, Response กับอีกอันคือ Decoupling เลย
  • ตอนผมทำ Rails, C# MVC, Phoenix ถึงแม้จะเป็น MVC เหมือนกันแต่สไตล์ก็แตกต่างกันมาก

ผมจึงอยากแชร์ว่าปกติแล้วผมดูอย่างไรว่าโค้ดไหนดีหรือโค้ดแบบไหนแย่ โดยไม่ขึ้นกับความคุ้นตาของตัวเอง

งั้นเรามาเริ่มกันเลยดีกว่า

Definition

กฎข้อแรกที่สำคัญที่สุดของโค้ดที่ดีสำหรับผมคือ

โค้ดที่ดีสำหรับผมคือมีนิยามของคำศัพท์ที่ชัดเจน และทำตามนิยามนั้น

ผมยกตัวอย่าง Rails บอกว่า Controller

Action Controller is the C in MVC. After the router has determined which controller to use for a request, the controller is responsible for making sense of the request, and producing the appropriate output. Luckily, Action Controller does most of the groundwork for you and uses smart conventions to make this as straightforward as possible.

แปลว่า Controller มีหน้าที่จัดการ Request และจัดการ Output ถ้าเรานิยามไว้แบบนี้ แปลว่าโค้ดที่เอา Controller ไปทำอย่างอื่นคือไม่ดีละ

แปลว่าผมอ่านโค้ด Rails ผมจะไม่อยากเห็น Controller ที่รู้เรื่องธุรกิจภายในมากกว่าการจัดการ Request/Response

class TransactionController
def create
  raise TooLowTransactionError if params[:amount] < 20 # บรรทัดนี้คือไม่ดี เพราะมันรู้ว่าในธุรกิจของเราห้ามสร้าง Transaction ต่ำกว่า 20 บาท
  # ....
end

ซึ่งแปลว่าถ้ามีใครก็ตามกำหนด Definition ของ Controller ต่างไปจากเรา เราอาจจะเอ๊ะในครั้งแรกว่าทำไมถึงกำหนดต่างกับมาตรฐานสากลล่ะ แต่ไม่ได้แปลว่าโค้ดไม่ดีนะครับ

สมมติใครซักคนกำหนดว่า Controller มีหน้าที่ประสานงานระหว่าง Model ในกรณีที่ API นั้นต้องใช้มากกว่า 1 Model แล้วเขาเขียนตามนั้น นี่ก็คือถือว่ายังเป็นโค้ดที่ดีในแง่ของ Honest to definition อยู่ครับ

ส่วนการกำหนด Definition แบบนี้มันเหมาะหรือไม่ จะทำให้ Controller บวมเกินเหตุมั้ย อันนั้นอีกเรื่องนึง ต้องไปดูตามบริบทของระบบที่พัฒนาอีกทีนึง

หรืออย่างเช่นใน MVC แบบ Rails ตัว Model จะเข้าถึงฐานข้อมูลได้ ก็จะเขียนบอกไว้ใน Definition ของ Model แต่ถ้าเราเริ่มทำ Hexagonal Architecture เราอาจจะมี Repository ที่เข้าถึงข้อมูลอีกที ไม่ให้ Model เขาถึงฐานข้อมูลโดยตรง

ทั้งสองอย่างผมมองว่าไม่ได้มีอันไหนเหนือกว่าอันไหน ขึ้นกับบริบทของระบบ

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

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

อันนี้ผมมองว่าแย่เสมอในทุกบริบทของระบบ

Consistency

ต่อเนื่องจากนิยาม ความคงเส้นคงวาก็สำคัญ

ผมขอยกย่อหน้าเก่าขึ้นมา

หรืออย่างเช่นใน MVC แบบ Rails ตัว Model จะเข้าถึงฐานข้อมูลได้ ก็จะเขียนบอกไว้ใน Definition ของ Model แต่ถ้าเราเริ่มทำ Hexagonal Architecture เราอาจจะมี Repository ที่เข้าถึงข้อมูลอีกที ไม่ให้ Model เขาถึงฐานข้อมูลโดยตรง

ถ้าสมมติเราวางนิยามไว้แบบนี้ แต่บางทีเวลาเห็น Model บวมเราก็สร้าง Repository ขึ้นมา บางทีก็ใช้ บางทีก็ไม่ใช้ ไม่มีความคงเส้นคงวา อันนี้ก็ถือว่าไม่ดี

ไม่ว่าจะทำโค้ดในภาษาโบราณแค่ไหนไปจนถึงใหม่แค่ไหน ความคงเส้นคงวาของคำศัพท์และ Layering คือดีครับ และข้อยกเว้นยิ่งมีมากคือไม่ดี

หรือสมมติถ้าเราตกลงกันแล้วว่าเราจะใช้ map เสมอ เราก็จะใช้มันเสมอ ไม่ใช้ for loop และตรงข้าม ถ้าเราตกลงกันแล้วว่าจะใช้ for loop เสมอไม่ใช้ map ก็ควรจะคงเส้นคงวากับสิ่งนั้น

ถ้าจะใช้ผสมกันก็ต้องบอกได้ว่าตอนไหนใช้ map ตอนไหนใช้ loop ที่ชัดเจนเข้าใจตรงกัน

การมีข้อยกเว้นได้มั้ย ได้ ถ้า Justify the cost ได้ แต่ถ้ามีไปเฉยๆ โดยไม่มีต้นสายปลายเหตุอธิบายได้ดีว่าทำไมถึงเกิดข้อยกเว้น อันนี้ผมมองว่าแย่โดยไม่เกี่ยวกับว่าออกแบบยังไง

Appropriate coupling

โค้ดที่ดีอีกอย่างนึงคือ เราเข้าใจว่าโค้ดแต่ละบรรทัดแก้ไขแล้วมีสิทธิ์กระทบอะไรบ้าง

ผมยกตัวอย่างจากบทความที่ผมเขียนเองเรื่อง โค้ดเหมือนจะ Clean

class Invoice
  def pay_invoice
    make_sure_invoice_approved
    create_transaction_entry
    decrease_company_total_balance
    record_tax_deduction
  end
end

ถ้าเราเขียนแบบนี้แล้วเราไม่สามารถทำความเข้าใจได้ว่าการแก้ไข create_transaction_entry จะกระทบกับใบเสร็จส่วนไหนบ้าง จนกว่าจะขุดลึกๆ อันนี้คือแย่

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

นั่นคือคุณลักษณะของโค้ดที่ดี โดยไม่เกี่ยวกับ Design pattern ที่ใช้

Local reasoning

คุณสมบัติสุดทายที่ผมจะพูดถึงวันนี้คือ Local reasoning หรือความสามารถในการทำความเข้าใจแยกส่วนได้

ถ้าคุณเห็นโค้ดแบบนี้

def run
  # ทำทุกอย่าง
end

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

แต่ถ้าเป็นแบบนี้ล่ะ

def vat
  @amount * 0.07
end

เราเร่ิมเข้าใจได้ละว่าบรรทัดนี้ขึ้นกับจำนวน @amount ในคลาสของเราเท่านั้น โดยมั่นใจได้ 100% ว่าจะไม่มี Surprise ใดๆ ออกมาที่จะมากระทบกับสิ่งนี้แบบงงๆ ได้ นอกจาก Amount

การที่เราสามารถทำความเข้าใจโค้ดเป็นส่วนๆ ได้ก็สำคัญ และเป็นสาเหตุนึงที่โค้ดที่ดีมักจจะมีขนาดของ Function, Method ที่เล็ก (แต่ผมว่าเราเชียร์ว่าทำฟังก์ชั่นเล็กๆ มากไปหน่อย จนหลายๆ คนเชื่อว่าฟังก์ชั่นเล็กคือดี โดยไม่เข้าใจว่าดียังไง ผมตอบให้ว่าจริงๆ เราอยากได้ความสามารถในการทำ Local reasoning)

ส่งท้าย

จริงๆ แล้วมันมีปัจจัยอีกมากมายที่ผมดู แต่วันนี้ขอเสนอ 4 ข้อครับ

  • Domain alignment
  • Naming convention
  • etc.

แต่ 4 ข้อนี้เป็นข้อพื้นฐานที่ผมคิดว่าถ้าเริ่มมองโค้ดที่ไม่คุ้นชินจากมุมนี้ เราจะเข้าใจง่ายขึ้นว่าโค้ดที่ดีเป็นแบบไหน รวมไปถึงถ้าเราไปศึกษา Framework ใหม่ โดยเข้าใจว่า Framework ที่ดีจะมีคุณสมบัติพวกนี้ เราก็จะเข้าใจและคาดเดา (Deduce) Design บางอย่างได้อย่างรวดเร็วครับ อ่านแป๊ปเดียวก็อ๋อ เขาไว้ยังงี้นี่เอง ได้เร็วขึ้นครับ

ถ้าชอบบทความนี้ ผมรบกวนกดให้กำลังใจใน dev.to ด้วยครับ บล็อกนี้ยังไม่ได้ทำระบบ Like หรือ View


Chris

Hi. I'm Chris.

A product builder, specialize in software engineering
I am currently working at ThoughtWorks