What it really mean by “Good code”
ผมเห็นดีเบตมากมายในการถามว่าอะไรคือโค้ดที่ดีที่สวยงาม
แต่ผมไม่ค่อยประทับใจมากนักเวลาคนพยายามกำหนด “กฎ” แห่งการโค้ด ว่าต้องทำแบบนั้น ต้องทำแบบนี้ แล้วนำมาถกเถียงกัน
แม้แต่ DHH ซึ่งเป็นเจ้าพ่อของ Ruby on rails ยังมีความเห็นไม่ตรงกับ Kent Beck เจ้าพ่อของ TDD
ส่วนตัวสำหรับผมแล้วเนี่ย Good code มันเป็นอะไรที่ง่ายกว่านั้น
Essence และแกนกลางของมันมีสองข้อ
- Testable สามารถทดสอบได้ ดั่งที่ Uncle Bob Martin บอกไว้ว่า “Something that hard to test is badly design” ข้อนี้เห็นด้วยทุกประการ ว่าถ้าเทสไม่ได้ ถึงจะอ่านง่ายยังไง ดูดียังไง มันก็คงถือว่าเป็นโค้ดที่ดีไม่ได้
- Readable อ่านง่าย
ข้อสองนี่แหละที่คนเถียงกันค่อนข้างเยอะว่า อะไรคือ “อ่านง่าย” อะไรคือ “อ่านยาก”
เถียงกันจนถึงขนาดที่ว่า Method ควรมีกี่บรรทัด อะไรคือ Single responsibility ต้องทำให้ทุกอย่างเล็กที่สุด
บางคนบอกว่าแยก Abstraction Layer เยอะไปจนอ่านไม่รู้เรื่องว่าอะไรคืออะไร ทำไมต้องเล็กขนาดนั้น ตามไม่ทัน บางคนบอกว่าต้องแยก Layer ให้เยอะๆ
ผมพบว่าจริงๆ เรื่อง Readable code มันไม่ได้อธิบายได้ด้วยกฎนะ ว่า “Method นึงต้องมี X บรรทัด” หรือไม่ได้อธิบายได้ด้วยว่า “Object นึงต้องมี 1 Responsibility” อะไรแบบนี้ แล้วจะอ่านง่าย
ส่วนตัว ผมไม่ค่อยศรัทธากฎพวกนี้ เพราะผมรู้สึกว่ามันหาที่มาที่ไปไม่ได้ มันเหมือนแค่เชื่อตามๆ กันมา
ผมเชื่อว่า สุดท้าย คนโค้ดก็คือมนุษย์
ผมเชื่อว่า โค้ดที่อ่านง่าย คือโค้ดที่มนุษย์อ่านรู้เรื่อง
ผมเชื่อว่า สุดท้ายแล้ว โค้ดที่อ่านง่าย (Readable)ใจความทั้งหมดมันอยู่ที่สิ่งที่ผมเรียกว่า “Human cognitive payload”
Human cognitive payload
ทฤษฎีนี้เริ่มมาจากความจริงง่ายๆ
“มนุษย์มีข้อจำกัดในการรับรู้จำสิ่งๆ นึงในช่วงเวลานึง”
ลองนึกถึงระบบง่ายๆ ระบบนึง
- Authen เข้ามา ด้วย User, password
- ขอข้อมูล
เราเขียนโค้ดออกมาได้ง่ายๆ
function getDataV1 (username, password, fieldName) {
if (!Authen.authen(username, password)) {
return null
}
const data = executeQuery("SELECT * FROM data")
return data[fieldName]
}เราจะไม่รู้สึกว่าโค้ดตรงนี้รกอะไรมาก ทั้งๆ ที่มันทำหน้าที่สองอย่าง
ถ้าเราเพิ่มเข้ามาล่ะว่า
- ถ้า Authen แล้วเป็น Admin ต้องให้ข้อมูลแบบนึง
- ถ้า Authen แล้วเป็น User ต้อง บันทึกด้วยว่าเขามาขอข้อมูล
- ถ้า Authen แล้วไม่พบ User ต้องทำการตีกลับ รวมไปถึง Block connection 50 วินาที
ตรงนี้ถ้าเรารวมทั้งหมดในฟังก์ชั่นเดิม ก็จะได้ประมาณนี้
function getDataV2 (username, password, key) {
const authen = Authen.authen(username, password)
if (authen.roles === 'admin') {
// ..... Do many things to get admin data
// .....
// .....
}
if (authen.roles === 'user') {
// ..... Get another type of data
// .....
// ..... Audit request
}
if (!authen) {
// ..... Block user connection
// .....
// .....
// ..... Audit request
}
}อันนี้แค่เขียนด้วย Comment หลายคนก็รู้สึกว่าเวียนหัวแล้ว
เพราะอะไรถึงรู้สึกเวียนหัวล่ะ?
มนุษย์มีสิ่งที่เรียกว่า Cognitive ability เรามีข้อจำกัด เราไม่สามารถรับรู้เรื่องหลายเรื่องพร้อมกันมากเกินไป
ดังนั้น ถึงแม้หลักการจะเหมือนกัน คือ “Authen แล้ว คืนข้อมูล”
แต่ในฟังก์ชั่นแรก สิ่งที่ต้องระวัง Context บริบทที่เราต้องรับรู้ระหว่างแก้โค้ด มันน้อย
แต่เวลาแก้โค้ดในฟังก์ชั่นที่สอง บริบทที่คุณต้องรับรู้ มันเยอะเกินไป ตั้งแต่ว่าถ้าเป็นกรณีนี้ต้องทำแบบนั้น กรณีนั้นต้องทำแบบนี้ กรณีโน้นต้องไม่ลืมอันนี้
มันมีหลายอย่างมากใน 1 ส่วนการทำงาน
คุณอาจจะแค่ต้องการแก้วิธีการ Audit เท่า แต่เพื่อหาสิ่งนั้น คุณต้องรับรู้บริบทมาตั้งแต่ว่า “ถ้าเป็น Admin ทำแบบนั้น ถ้าเป็น User ทำแบบนี้”
ทำให้เกิดสิ่งที่เรียกว่า “Cognitive overload” คือ เราต้องรู้บริบทมากเกินไปถึงจะเข้าใจสิ่งนี้ได้
Premature Abstraction
พอมาถึงตรงนี้ หลายคนก็บอกว่า “ถ้างั้นก็ Abstract ให้ Method เล็กที่สุดและมีหน้าที่เดียวเสมอสิ จะได้รับประกันว่าอ่านง่าย เพราะไม่ต้องรู้เยอะ”
ผมว่าไม่แน่เสมอไปนะครับ
เราลองเขียน Function getDataV1 ใหม่ด้วยการ Abstraction ให้เล็กที่สุดนะครับ
function getDataV3 (username, password, fieldName) {
const dbContext = DbContextSingleton.getInstance()
const authen = AuthenFactory.getAuthenticator(dbContext)
if (!authen(username, password)) {
return null
}
const QueryExecutor = QueryExecutorFactory.createInstance(dbContext, authen)
const SqlGenerator = SqlGeneratorFactory.create('mySql')
const sql = SqlGenerator.CreateQueryForData(fieldName)
const result = QueryExecutor.execute(sql)
return result}
โอเค ตอนนี้เรามีทุกอย่างที่เล็กที่สุด
จาก เดิมที่ Authen ง่ายๆ ทำทุกอย่างเอง ตอนนี้เรามี DbContext ซึ่งสร้างด้วย Singleton ทำหน้าที่เล็กๆ ในการเชื่อมฐานข้อมูล
แล้วเรานำ Singleton นั้นเข้าไป Inject ที่ AuthenFactor เพื่อให้ได้ Authenticator ที่ใช้ฐานข้อมูลตัวน ที่ทำหน้าที่เล็กๆ ในการตรวจสอบ Authen
หลังจากนั้นเรามีอีกคลาสนึงที่ทำหน้าที่เล็กๆ แค่การ ExecuteQuery ซึ่งได้มาจากการสร้างโดย QueryExecutorFactor โดย Inject ฐานข้อมูลและ Authen เข้าไป
หลังจากนั้นเรามีอีกคลาสนึงที่ทำหน้าที่เล็กๆ แค่การสร้าง SQL โดยเกิดจาก SqlGeneratorFactor โดยใส่ภาษาเข้าไป
เราจึงเอา Sql ที่เกิดจาก SqlGenerator มารวมผสมกับ QueryExecutor เพื่อยิงคำสั่งไปยังฐานข้อมูล แล้วดึงกลับมา
….
สำหรับผม ผมว่าแบบนี้อ่านยากกว่า แบบแรก getDataV1 เยอะเลยนะครับ (และผมเชื่อว่าคนส่วนมากน่าจะเหมือนผมนะ)
เพราะกว่าผมจะอ่านสิ่งนี้รู้เรื่อง ผมต้องรู้ก่อนว่า เห้ย มันต้องไปดึงตรงนั้นมาจากที่นี้ ไปดึงอันนี้มาจากที่นั้น เอามาประกอบกันยังไงได้บ้าง ถึงจะสามารถเข้าใจได้
มันมี Cognitive payload ที่ผมต้องรู้เรื่องบริบทเยอะมาก ถึงจะเข้าใจโค้ดนี้ได้ ไม่ต่างกับ getDataV2 เลย แต่แค่บริบทของโค้ดนี้ เป็น Design pattern ที่ส่วนมากคนที่เรียน OOP และมีประสบการณ์ จะรู้จัก ก็เท่านั้น
โค้ดที่ใช้หลักการ Design Pattern อย่างครบถ้วนทุกกระบวนท่า กลับกลายเป็นว่าอ่านยาก ถ้าทำแบบกับ getDataV1
แต่ผมเห็นด้วยนะ ถ้าจะใช้มันกับ getDataV2
Code must be designed
มาถึงตรงนี้แล้ว คงจะเริ่มงงว่า แล้วไอ้คนเขียนบทความจะสื่ออะไร คือแบบนี้ กูทำอะไรก็ผิดใช่ป่ะ จะทำแบบง่ายๆ ก็ผิดหลัก OOP อีก อ่านยากอีก จะทำแบบตามตำราเป๊ะๆ ก็ผิดอีกอ่านยากอ่านไม่รู้เรื่องอีก คือ จะเอาอะไรจากกูวะเนี่ย ทำยังไงมันถึงจะถูกวะตกลง
คุณรู้มั้ยครับว่า ในวงการซอฟต์แวร์ มีคนที่ต้องเผชิญกับสถานการณ์นี้ตลอดเวลา
“Designer”
ใช่ครับ ในโลกของการดีไซน์แอพ ไม่มีคำตอบที่ถูกเลย เค้าต้องคิดแล้ว คิดอีก คิดในมุมของ User ว่า อะไรคือ Minimal ปุ่มนี้ควรจะสีไหน วางไว้ตรงไหน
มันมีแค่หลักคร่าวๆ แต่ ไม่มีคำตอบที่ถูก ไม่มีใครบอกว่า แอพที่มีปุ่มสีฟ้าคือแอพที่สวยกว่าปุ่มสีเหลือง เพราะ Steve Jobs ได้กล่าวไว้
ดีไซน์เนอร์ที่เก่ง ไม่ได้เก่งเพราะทำตามตำรา แต่เค้าเก่งเพราะ เค้าคิดถึง User
ตำราใช้ได้เป็นแค่แผนที่ แต่สุดท้ายเวลาทำงาน สิ่งที่เขาทำคือ เขามองว่า User จะคิดยังไง เขาถึงได้ดีไซน์แอพที่ดีได้ ทั้งๆ ที่บางทีแอพที่ใช้ง่ายจริงๆ ประเภทเปลี่ยนโลกเนี่ย ไม่ได้ตรงตามตำราของยุคนั้นเลย แต่ก็ต่อยอดมาจากตำรายุคนั้น
และมันไม่มีคำตอบที่ถูกนะ ดีไซน์เนอร์ ไม่มีวันเจอคำตอบที่ถูกเลยตลอดชีวิตการทำงาน เป็นคนที่ทำได้แค่ “ดีที่สุดในเวลานั้นที่เขามีปัญญาคิด”
เช่นกันครับ “Clean code must be designed” (not engineered)
โค้ดที่อ่านง่าย ต้องมาจากการออกแบบดีไซน์เสมอ
การดีไซน์มันไม่มีคำตอบที่ถูก เราทำได้แค่พยายามคิดถึงคนที่จะใช้ต่อให้ดีที่สุดเท่านั้นแหละครับ
เรามีไกด์ไลน์ต่างๆ มากมาย จากทั้ง OOP Pattern, Functional programming, etc.. เฉกเช่นเดียวกับดีไซน์เนอร์ที่มีไกด์ไลน์มากมายยิ่งนัก
แต่….. สุดท้าย เฉกเช่นเดียวกับดีไซน์เนอร์ ที่เขาเอาไกด์ไลน์เหล่านั้นมาเทียบกับสถานการณ์จริงของผู้ใช้ ผู้ใช้ใช้งานยังไง อยู่ในสภาวะไหน ใช้เพื่อเป้าหมายอะไร ระหว่างที่เขาอยู่หน้านี้เขาทำอะไรอยู่
เราก็เช่นกัน เราก็ต้องคิดว่า โปรแกรมเมอร์ที่จะเข้ามาแก้ เขาน่าจะแก้ยังไง บริบทที่เขาจะเสพมันเยอะขนาดไหน ก่อนที่จะมาถึงจุดนี้เขาจะต้องเรียนอะไรมาแล้วบ้าง อะไรบ้างที่เป็นบริบทที่เขารู้อยู่แล้ว อะไรบ้างที่เขาไม่น่าจะรู้มาก่อน แล้วเราจะให้เขาเสพบริบทเยอะขนาดไหนถึงจะอยู่ในขอบเขตที่พอรับได้
มันเป็นเรื่องของการ “ออกแบบ” Cognitive payload ที่คนแก้โค้ดคนถัดไปจะได้รับ ให้ไม่มากเกินไป ไม่น้อยเกินไป
ผมเชื่อว่าสุดท้าย “กุญแจ” ของโค้ดที่อ่านง่าย มันอยู่แค่นี้
“มนุษย์มีความสามารถในการรับบริบทที่จำกัด ทำยังไงให้เขาสามารถโฟกัสกับสิ่งที่เขาต้องการแก้ไข โดยไม่ต้องศึกษาบริบทเพิ่มเติมเยอะเกินไป”
ทุกอย่างไม่ว่าจะ SRP, Functional, etc.. ทุกอย่าง ผมเชื่อว่ามันมีแกนกลางอยู่ตรงนี้ครับ (ไม่นับ Testable นะ)
สมมติ ถ้าคุณเขียน Haskell คุณก็ Expect ได้ว่าโปรแกรมเมอร์ที่เข้ามาดูโค้ดจะรู้บริบทบางอย่าง และไม่รู้บริบทบางอย่าง ตรงนี้แหละที่คุณต้องดีไซน์ และออกแบบแล้ว ว่าคุณมีสมมติฐานแบบไหน เหมือนดีไซน์เนอร์ที่เก่งกล้า ที่ต้องรู้ว่า User ที่จะใช้เป็นคนยังไง ไม่ใช่ออกแบบมาแบบ One size fit all
นั่นแหละคือความยากของการ “ออกแบบ”
และสุดท้าย น่าเศร้าที่ถ้าเราอยากจะดีไซน์โค้ดให้ดีจริงๆ เราต้องยอมรับโลกของการออกแบบที่ว่าสุดท้ายแล้ว “มันไม่มีวันมีคำตอบที่ถูกต้อง”
ส่ิงที่ผมจะ Encourage เสมอถ้าต้องการโค้ดที่ดี
- มันไม่มีคำตอบที่ถูกต้อง ดังนั้น เราต้องแลกเปลี่ยนกันเยอะๆ แน่นอนดีไซน์เนอร์เก่งๆ ควรจะรู้ Guideline และเทรนด์ โปรแกรมเมอร์ที่ดีก็ควรจะรู้ Design pattern และหลักการเหมือนกัน
- มันไม่มีคำตอบที่ถูกต้อง ดังนั้น เราทำได้แค่ว่าทำให้ดีที่สุดเท่าที่เรานึกออกวันนี้ ถ้ามันยังไม่ดี ไม่เป็นไร กลับมาแก้ไขใหม่ อย่ากลัวที่จะ “ทำผิดพลาด ไม่เป็น Best practice” เพราะมันไม่มีจริง
- อย่าใช้ความกลัวในการปกครองทีมให้เขียนโค้ดที่สวยงาม สิ่งที่เป็นปฏิปักษ์กับการ “ออกแบบ” มากที่สุดคือความกลัว การออกแบบที่ดีเกิดขึ้นเมื่อสมองโปร่ง ไม่ใช่เมื่อสมองนอยด์
ผมเสนอ Guideline ที่ผมคิดว่าคนพูดถึงน้อย คือการดีไซน์โค้ดโดยนึกถึง Human cognitive payload ว่าคนอ่านนั้น ก่อนจะมาเจอโค้ดบรรทัดนี้ เขาจะรู้บริบทอะไรบ้างแล้ว เขาจะรู้จัก Factory pattern มั้ย เขาจะรู้มั้ยว่าระบบเราต้องไปขอ Authen จากไหน เราจะทำยังไงให้เขาต้องรู้เท่าที่จำเป็น ไม่มากไปไม่น้อยเกินไป
ซึ่งผมคิดว่า เอาไปใช้ได้จริงครับ