| Topic 31 | ภาษี Inheritance |
|---|
คุณอยากได้กล้วยสักลูก แต่สิ่งที่ได้กลับมาคือกอริลลาที่ถือกล้วยพร้อมกับป่าทั้งป่า
Joe Armstrong
คุณเขียนโปรแกรมด้วยภาษาเชิงวัตถุ (object-oriented) อยู่หรือเปล่าครับ แล้วคุณใช้ Inheritance ด้วยไหม
ถ้าใช่ หยุดก่อนครับ เพราะนั่นอาจไม่ใช่สิ่งที่คุณต้องการทำจริงๆ
มาดูกันว่าทำไมครับ
Some Background (เบื้องหลังความเป็นมา)
Inheritance ปรากฏขึ้นครั้งแรกใน Simula 67 เมื่อปี 1969 มันเป็นทางออกที่สวยงามสำหรับปัญหาการเข้าคิวของเหตุการณ์หลายประเภทในลิสต์เดียวกัน แนวทางของ Simula คือการใช้สิ่งที่เรียกว่า prefix classes คุณสามารถเขียนอะไรทำนองนี้ได้ครับ
| | link CLASS car; |
|---|---|
| | ... implementation of car |
| | |
| | link CLASS bicycle; |
| | ... implementation of bicycle |
link ในที่นี้คือ prefix class ที่เพิ่มความสามารถของลิงก์ลิสต์ (linked list) เข้าไป ซึ่งทำให้คุณสามารถเพิ่มทั้งรถยนต์และจักรยานลงในลิสต์ของสิ่งที่กำลังรอ (สมมติว่า) ที่สัญญาณไฟจราจรได้ ในศัพท์ปัจจุบัน link ก็คือ parent class นั่นเอง
โมเดลทางความคิดที่โปรแกรมเมอร์ Simula ใช้คือ ข้อมูลอินสแตนซ์และการทำงานของคลาส link ถูกนำไปต่อไว้ด้านหน้าการทำงานของคลาส car และ bicycle ส่วนของ link แทบจะถูกมองว่าเป็น container ที่พารถยนต์และจักรยานติดตัวไปด้วย นี่ทำให้เกิด polymorphism รูปแบบหนึ่ง นั่นคือรถยนต์และจักรยานต่างก็ implement interface link เพราะทั้งคู่มีโค้ดของ link อยู่ในตัว
หลังจาก Simula ก็มาถึง Smalltalk Alan Kay หนึ่งในผู้สร้าง Smalltalk อธิบายไว้ในคำตอบบน Quora เมื่อปี 2019 [44] ว่าทำไม Smalltalk ถึงมี Inheritance
ตอนที่ผมออกแบบ Smalltalk-72 นั้น—ซึ่งเป็นงานอดิเรกสนุกๆ ที่ทำขณะกำลังคิดเรื่อง Smalltalk-71—ผมคิดว่ามันคงสนุกดีถ้าจะใช้พลวัตแบบ Lisp ของมันมาทดลองกับ “differential programming” (หมายถึง วิธีการต่างๆ ในการทำสิ่งที่ “เหมือนอันนี้ ยกเว้นแต่”)
นี่คือการ subclass เพื่อพฤติกรรมล้วนๆ
Inheritance สองสไตล์นี้ (ซึ่งจริงๆ แล้วมีจุดร่วมกันไม่น้อย) พัฒนาต่อมาตลอดหลายทศวรรษ แนวทางของ Simula ที่มองว่า Inheritance เป็นวิธีการรวมชนิดข้อมูลเข้าด้วยกัน ยังคงสืบทอดต่อในภาษาอย่าง C++ และ Java ส่วนสำนัก Smalltalk ที่มอง Inheritance เป็นการจัดระเบียบพฤติกรรมแบบไดนามิก ก็ปรากฏในภาษาอย่าง Ruby และ JavaScript
ดังนั้นตอนนี้เราจึงเจอกับนักพัฒนา OO รุ่นหนึ่งที่ใช้ Inheritance ด้วยเหตุผลอย่างใดอย่างหนึ่งในสองข้อ คือไม่ชอบพิมพ์เยอะๆ หรือไม่ก็ชอบเรื่องชนิดข้อมูล (types)
คนที่ไม่ชอบพิมพ์เยอะๆ จะประหยัดแรงนิ้วโดยใช้ Inheritance เพื่อดึงฟังก์ชันการทำงานร่วมจาก base class เข้ามาใน child class เช่น class User และ class Product ต่างก็เป็น subclass ของ ActiveRecord::Base
ส่วนคนที่ชอบเรื่อง types จะใช้ Inheritance เพื่อสื่อความสัมพันธ์ระหว่างคลาส เช่น Car เป็น is-a-kind-of ของ Vehicle
น่าเสียดายที่ Inheritance ทั้งสองแบบนี้ล้วนมีปัญหาครับ
Problems Using Inheritance to Share Code (ปัญหาของการใช้ Inheritance เพื่อแชร์โค้ด)
Inheritance คือ การ coupling ไม่ใช่แค่ child class ที่ผูกติดกับ parent, parent ของ parent และต่อไปเรื่อยๆ เท่านั้น แต่โค้ดที่ ใช้ child class ก็ผูกติดกับบรรพบุรุษทั้งหมดนั้นด้วย ลองดูตัวอย่างนี้ครับ
| | class Vehicle |
|---|---|
| | def initialize |
| | @speed = 0 |
| | end |
| | def stop |
| | @speed = 0 |
| | end |
| | def move_at(speed) |
| | @speed = speed |
| | end |
| | end |
| | |
| | class Car < Vehicle |
| | def info |
| | "I'm car driving at #{ @speed } " |
| | end |
| | end |
| | |
| | # top-level code |
| | my_ride = Car.new |
| | my_ride.move_at(30) |
เมื่อ top-level เรียก my_car.move_at เมธอดที่ถูกเรียกใช้จริงๆ อยู่ใน Vehicle ซึ่งเป็น parent ของ Car
ทีนี้นักพัฒนาที่ดูแล Vehicle เปลี่ยน API ทำให้ move_at กลายเป็น set_velocity และตัวแปรอินสแตนซ์ @speed กลายเป็น @velocity
การเปลี่ยน API ย่อมคาดหมายได้ว่าจะทำให้ client ของคลาส Vehicle พังไป แต่ top-level ไม่ควรจะพังด้วย เพราะเท่าที่มันรู้ มันกำลังใช้ Car อยู่ สิ่งที่คลาส Car ทำในแง่ของการ implementation ไม่ใช่เรื่องที่ top-level ต้องสนใจ แต่มันก็ยังพังอยู่ดี
เช่นเดียวกัน ชื่อของตัวแปรอินสแตนซ์ก็เป็นรายละเอียดภายในล้วนๆ แต่เมื่อ Vehicle เปลี่ยน มันก็ทำให้ Car พังไปด้วย (แบบเงียบๆ)
coupling เยอะขนาดนี้เลยครับ
Problems Using Inheritance to Build Types (ปัญหาของการใช้ Inheritance เพื่อสร้างชนิดข้อมูล)
บางคนมองว่า Inheritance เป็นวิธีนิยามชนิดข้อมูลใหม่ ไดอะแกรมออกแบบที่พวกเขาชื่นชอบคือแผนภูมิลำดับชั้นของคลาส (class hierarchies) พวกเขามองปัญหาแบบเดียวกับที่นักวิทยาศาสตร์สุภาพบุรุษยุควิกตอเรียมองธรรมชาติ นั่นคือสิ่งที่ต้องถูกแบ่งย่อยออกเป็นหมวดหมู่
แต่น่าเสียดายที่ไดอะแกรมเหล่านี้มักโตขึ้นจนกลายเป็นอสุรกายที่ปกคลุมทั้งกำแพง มีชั้นแล้วชั้นเล่าถูกเพิ่มเข้ามาเพื่อสื่อความแตกต่างเล็กๆ น้อยๆ ระหว่างคลาส ความซับซ้อนที่เพิ่มขึ้นนี้ทำให้แอปพลิเคชันเปราะบางขึ้น เพราะการเปลี่ยนแปลงสามารถกระเพื่อมขึ้นลงผ่านหลายชั้นได้
แย่ไปกว่านั้นคือปัญหาเรื่อง multiple inheritance Car อาจเป็น Vehicle ชนิดหนึ่ง แต่ก็อาจเป็น Asset , InsuredItem , LoanCollateral และอื่นๆ ได้ด้วย การจะโมเดลสิ่งนี้ให้ถูกต้องจำเป็นต้องใช้ multiple inheritance
C++ ทำให้ multiple inheritance มีชื่อเสียงในทางลบตั้งแต่ยุค 1990s เพราะซีแมนติกส์การแก้ความกำกวม (disambiguation) ที่ชวนสงสัย ผลก็คือภาษา OO ในปัจจุบันหลายภาษาจึงไม่มีฟีเจอร์นี้ให้ใช้ ดังนั้นแม้คุณจะโอเคกับแผนภูมิชนิดข้อมูลที่ซับซ้อน คุณก็ยังไม่สามารถโมเดลโดเมนของคุณได้อย่างแม่นยำอยู่ดี
| Tip 51 | อย่าจ่ายภาษี Inheritance |
|---|
The Alternatives Are Better (ทางเลือกที่ดีกว่า)
ขอแนะนำสามเทคนิคที่จะทำให้คุณไม่ต้องใช้ Inheritance อีกเลยครับ
- Interfaces และ protocols
- Delegation
- Mixins และ traits
Interfaces and Protocols (Interfaces และ Protocols)
ภาษา OO ส่วนใหญ่อนุญาตให้คุณระบุว่าคลาสหนึ่ง implement พฤติกรรมชุดหนึ่งหรือมากกว่า คุณอาจบอกได้ เช่น class Car implement พฤติกรรม Drivable และพฤติกรรม Locatable ไวยากรณ์ที่ใช้ทำแบบนี้แตกต่างกันไปในแต่ละภาษา ในภาษา Java อาจหน้าตาแบบนี้ครับ
| | public class Car implements Drivable, Locatable { |
|---|---|
| | |
| | // Code for class Car. This code must include |
| | // the functionality of both Drivable |
| | // and Locatable |
| | |
| | } |
Drivable และ Locatable คือสิ่งที่ Java เรียกว่า interfaces ส่วนภาษาอื่นเรียกมันว่า protocols และบางภาษาก็เรียกว่า traits (แม้ว่านี่จะไม่ใช่สิ่งที่เราจะเรียกว่า trait ในภายหลัง)
Interfaces ถูกนิยามแบบนี้ครับ
| | public interface Drivable { |
|---|---|
| | double getSpeed(); |
| | void stop(); |
| | } |
| | |
| | public interface Locatable() { |
| | Coordinate getLocation(); |
| | boolean locationIsValid(); |
| | } |
การประกาศแบบนี้ไม่ได้สร้างโค้ดขึ้นมาเลย มันแค่บอกว่าคลาสใดก็ตามที่ implement Drivable ต้อง implement สองเมธอด getSpeed และ stop และคลาสที่เป็น Locatable ต้อง implement getLocation และ locationIsValid ซึ่งหมายความว่านิยามคลาส Car ก่อนหน้านี้ของเราจะถูกต้องก็ต่อเมื่อมันมีทั้งสี่เมธอดนี้ครบ
สิ่งที่ทำให้ interfaces และ protocols ทรงพลังคือเราสามารถใช้มันเป็น types ได้ และคลาสไหนก็ตามที่ implement interface ที่เหมาะสมก็จะเข้ากันได้กับ type นั้น ถ้า Car และ Phone ต่าง implement Locatable ทั้งคู่ เราก็สามารถเก็บทั้งสองไว้ในลิสต์ของ locatable items ได้ครับ
| | List<Locatable> items = new ArrayList<>(); |
|---|---|
| | |
| | items.add( new Car(...)); |
| | items.add( new Phone(...)); |
| | items.add( new Car(...)); |
| | // ... |
จากนั้นเราก็สามารถประมวลผลลิสต์นั้นได้ โดยมั่นใจได้ว่าทุกไอเทมมี getLocation และ locationIsValid ครับ
| | void printLocation(Locatable item) { |
|---|---|
| | if (item.locationIsValid() { |
| | print(item.getLocation().asString()); |
| | } |
| | |
| | // ... |
| | |
| | items.forEach(printLocation); |
| Tip 52 | เลือกใช้ Interfaces เพื่อสื่อ Polymorphism |
|---|
Interfaces และ protocols ให้ polymorphism แก่เราโดยไม่ต้องพึ่ง Inheritance
Delegation (Delegation)
Inheritance สนับสนุนให้นักพัฒนาสร้างคลาสที่อ็อบเจกต์มีเมธอดจำนวนมาก ถ้า parent class มี 20 เมธอด และ subclass ต้องการใช้แค่ 2 เมธอดในนั้น อ็อบเจกต์ของมันก็ยังจะมีอีก 18 เมธอดที่เหลือลอยอยู่และเรียกใช้ได้อยู่ดี คลาสจึงสูญเสียการควบคุม interface ของตัวเอง นี่เป็นปัญหาที่พบได้บ่อย เฟรมเวิร์กด้าน persistence และ UI จำนวนมากยืนกรานให้คอมโพเนนต์ของแอปพลิเคชัน subclass มาจาก base class ที่มันจัดเตรียมไว้ให้ เช่น
| | class Account < PersistenceBaseClass |
|---|---|
| | end |
ตอนนี้คลาส Account ต้องแบกรับ API ทั้งหมดของคลาส persistence ติดตัวไปด้วย แทนที่จะเป็นแบบนั้น ลองนึกถึงทางเลือกที่ใช้ delegation อย่างตัวอย่างต่อไปนี้ครับ
| | class Account |
|---|---|
| | def initialize(. . .) |
| | @repo = Persister.for(self) |
| | end |
| | |
| | def save |
| | @repo.save() |
| | end |
| | end |
ตอนนี้เราไม่เปิดเผย API ของเฟรมเวิร์กให้ client ของคลาส Account เห็นเลย coupling นั้นถูกตัดขาดแล้ว แต่ยังไม่หมดแค่นั้น เพราะตอนนี้เราไม่ถูกจำกัดด้วย API ของเฟรมเวิร์กที่เราใช้อีกต่อไป เราจึงเป็นอิสระที่จะสร้าง API ที่เราต้องการเอง ใช่ครับ เราทำแบบนี้ได้ตั้งแต่ก่อนหน้านี้แล้วเหมือนกัน แต่เราก็เสี่ยงตลอดว่า interface ที่ เรา เขียนขึ้นมาจะถูกข้ามไปใช้ persistence API แทน ตอนนี้เราควบคุมทุกอย่างได้เต็มที่แล้วครับ
| Tip 53 | Delegate ไปยัง Services: Has-A ชนะ Is-A |
|---|
จริงๆ แล้วเราไปได้ไกลกว่านี้อีก ทำไม Account ต้องรู้วิธี persist ตัวเองด้วย ในเมื่อหน้าที่ของมันคือการรู้และบังคับใช้กฎทางธุรกิจของบัญชีไม่ใช่หรือ
| | class Account |
|---|---|
| | # nothing but account stuff |
| | end |
| | |
| | class AccountRecord |
| | # wraps an account with the ability |
| | # to be fetched and stored |
| | end |
ตอนนี้เราแยกส่วนกันได้อย่างแท้จริงแล้ว แต่มันก็มาพร้อมกับต้นทุนเหมือนกัน เราต้องเขียนโค้ดมากขึ้น และบางส่วนก็มักจะเป็น boilerplate เช่น คลาส record ของเราแทบทุกอันคงต้องมีเมธอด find เหมือนกันหมด
โชคดีที่นี่คือสิ่งที่ mixins และ traits ทำให้เราได้ครับ
Mixins, Traits, Categories, Protocol Extensions, … (Mixins, Traits, Categories, Protocol Extensions และอื่นๆ)
ในฐานะอุตสาหกรรม เราชอบตั้งชื่อให้กับทุกสิ่ง และบ่อยครั้งเราก็ตั้งชื่อให้สิ่งเดียวกันหลายชื่อ ยิ่งเยอะยิ่งดีใช่ไหมล่ะ
นั่นแหละคือสิ่งที่เรากำลังเจอเมื่อพูดถึง mixins แนวคิดพื้นฐานนั้นเรียบง่าย เราอยากขยายความสามารถของคลาสและอ็อบเจกต์ด้วยฟังก์ชันการทำงานใหม่ๆ โดยไม่ต้องใช้ Inheritance เราจึงสร้างชุดฟังก์ชันเหล่านี้ขึ้นมา ตั้งชื่อให้ชุดนั้น แล้วก็ขยายคลาสหรืออ็อบเจกต์ด้วยมันด้วยวิธีใดวิธีหนึ่ง เมื่อถึงจุดนั้น คุณก็จะได้คลาสหรืออ็อบเจกต์ใหม่ที่รวมความสามารถของตัวเดิมกับ mixin ทั้งหมดเข้าด้วยกัน ในกรณีส่วนใหญ่ คุณจะทำการขยายนี้ได้แม้จะไม่มีสิทธิ์เข้าถึงซอร์สโค้ดของคลาสที่คุณกำลังขยายอยู่ก็ตาม
ทีนี้การ implement และชื่อของฟีเจอร์นี้ก็แตกต่างกันไปในแต่ละภาษา เราจะเรียกมันว่า mixins ในที่นี้ แต่จริงๆ แล้วอยากให้คุณมองว่านี่เป็นฟีเจอร์ที่ไม่ผูกติดกับภาษาใดภาษาหนึ่ง สิ่งสำคัญคือความสามารถที่ implementation ทั้งหมดเหล่านี้มีร่วมกัน นั่นคือการผสานฟังก์ชันการทำงานระหว่าง สิ่งที่มีอยู่แล้ว กับ สิ่งใหม่
ยกตัวอย่างเช่น ลองย้อนกลับไปที่ตัวอย่าง AccountRecord ของเรา ตามที่เราทิ้งไว้ล่าสุด AccountRecord ต้องรู้ทั้งเรื่องบัญชีและเรื่องเฟรมเวิร์ก persistence ของเรา มันยังต้อง delegate ทุกเมธอดในเลเยอร์ persistence ที่มันอยากเปิดเผยให้โลกภายนอกเห็นด้วย
Mixins ให้ทางเลือกอื่นแก่เรา ก่อนอื่นเราสามารถเขียน mixin ที่ implement (เช่น) สองในสามของเมธอด finder มาตรฐานได้ จากนั้นก็เพิ่มมันเข้าไปใน AccountRecord เป็น mixin และเมื่อเราเขียนคลาสใหม่ๆ สำหรับสิ่งที่ persisted เราก็เพิ่ม mixin นี้เข้าไปด้วยเช่นกัน
| | mixin CommonFinders { |
|---|---|
| | def find(id) { ... } |
| | def findAll() { ... } |
| | end |
| | |
| | class AccountRecord extends BasicRecord with CommonFinders |
| | class OrderRecord extends BasicRecord with CommonFinders |
เราไปได้ไกลกว่านี้อีกมาก ยกตัวอย่างเช่น เราทุกคนรู้ว่า business object ของเราต้องการโค้ด validation เพื่อป้องกันข้อมูลเสียไม่ให้แทรกซึมเข้ามาในการคำนวณของเรา แต่จริงๆ แล้วเราหมายถึงอะไรกันแน่เมื่อพูดถึง validation ครับ
ถ้าเรายกตัวอย่าง account ก็อาจมีชั้นของ validation ที่แตกต่างกันหลายชั้นที่นำมาใช้ได้ เช่น
- ตรวจสอบว่ารหัสผ่านที่ hash แล้วตรงกับที่ผู้ใช้กรอกเข้ามา
- ตรวจสอบข้อมูลฟอร์มที่ผู้ใช้กรอกตอนสร้างบัญชี
- ตรวจสอบข้อมูลฟอร์มที่ผู้ดูแลระบบกรอกตอนอัปเดตรายละเอียดผู้ใช้
- ตรวจสอบข้อมูลที่ถูกเพิ่มเข้ามาในบัญชีโดยคอมโพเนนต์อื่นๆ ของระบบ
- ตรวจสอบความสอดคล้องของข้อมูลก่อนที่จะถูก persist
แนวทางที่พบได้ทั่วไป (และเราเชื่อว่าไม่ค่อยเหมาะเท่าไหร่) คือการรวม validation ทั้งหมดไว้ในคลาสเดียว (business object/persistence object) แล้วเพิ่ม flag เพื่อควบคุมว่าจะทำงานในสถานการณ์ไหนบ้าง
เราคิดว่าวิธีที่ดีกว่าคือการใช้ mixins เพื่อสร้างคลาสเฉพาะทางสำหรับแต่ละสถานการณ์ครับ
| | class AccountForCustomer extends Account |
|---|---|
| | with AccountValidations,AccountCustomerValidations |
| | |
| | class AccountForAdmin extends Account |
| | with AccountValidations,AccountAdminValidations |
ตรงนี้ทั้งสองคลาสที่สืบทอดมารวม validation ที่ใช้ร่วมกันของ account object ทุกตัวไว้ด้วยกัน ส่วนตัวแปร customer ยังรวม validation ที่เหมาะกับ API ที่หันหน้าเข้าหาลูกค้า ในขณะที่ตัวแปร admin มี validation ของฝั่ง admin (ซึ่งคาดว่าน่าจะเข้มงวดน้อยกว่า) รวมอยู่ด้วย
ทีนี้เมื่อส่งอินสแตนซ์ของ AccountForCustomer หรือ AccountForAdmin ไปมา โค้ดของเราก็มั่นใจได้ โดยอัตโนมัติ ว่า validation ที่ถูกต้องถูกนำมาใช้แล้ว
| Tip 54 | ใช้ Mixins เพื่อแชร์ฟังก์ชันการทำงาน |
|---|
Inheritance Is Rarely the Answer (Inheritance แทบไม่ใช่คำตอบ)
เราได้ดูทางเลือกสามแบบสำหรับการ inherit คลาสแบบดั้งเดิมไปคร่าวๆ แล้วครับ
- Interfaces และ protocols
- Delegation
- Mixins และ traits
แต่ละวิธีอาจเหมาะกับคุณมากกว่าในสถานการณ์ที่ต่างกัน ขึ้นอยู่กับว่าเป้าหมายของคุณคือการแชร์ข้อมูลชนิดข้อมูล เพิ่มฟังก์ชันการทำงาน หรือแชร์เมธอด เหมือนกับทุกอย่างในการเขียนโปรแกรม ให้มุ่งใช้เทคนิคที่สื่อความตั้งใจของคุณได้ดีที่สุดครับ
และพยายามอย่าลากทั้งป่าติดตัวไปด้วยนะครับ
Related Sections Include (หัวข้อที่เกี่ยวข้อง)
- Topic 8, The Essence of Good Design
- Topic 10, Orthogonality
- Topic 28, Decoupling
Challenges (แบบฝึกหัด)
- ครั้งต่อไปที่คุณพบว่าตัวเองกำลัง subclass ลองใช้เวลาสักครู่พิจารณาทางเลือกอื่นดูครับ คุณสามารถบรรลุสิ่งที่ต้องการด้วย interfaces, delegation และ/หรือ mixins ได้ไหม คุณลด coupling ได้ด้วยวิธีนี้หรือเปล่า