ฉันเห็นคนถามตลอดเวลาว่าควรรวมการสืบทอดหลายรายการไว้ใน C # หรือ Java เวอร์ชันถัดไปหรือไม่ ชาว C ++ ที่โชคดีพอที่จะมีความสามารถนี้บอกว่านี่เหมือนกับการให้เชือกใครสักคนเพื่อแขวนคอตัวเองในที่สุด
มรดกหลายรายการมีอะไรบ้าง? มีตัวอย่างคอนกรีตหรือไม่?
ฉันเห็นคนถามตลอดเวลาว่าควรรวมการสืบทอดหลายรายการไว้ใน C # หรือ Java เวอร์ชันถัดไปหรือไม่ ชาว C ++ ที่โชคดีพอที่จะมีความสามารถนี้บอกว่านี่เหมือนกับการให้เชือกใครสักคนเพื่อแขวนคอตัวเองในที่สุด
มรดกหลายรายการมีอะไรบ้าง? มีตัวอย่างคอนกรีตหรือไม่?
คำตอบ:
ปัญหาที่ชัดเจนที่สุดคือการลบล้างฟังก์ชัน
สมมติว่ามีสองชั้นA
และซึ่งทั้งสองวิธีการที่กำหนดB
doSomething
ตอนนี้คุณกำหนดคลาสที่สามC
ซึ่งสืบทอดมาจากทั้งสองA
และB
แต่คุณไม่ได้แทนที่doSomething
เมธอด
เมื่อคอมไพเลอร์เมล็ดรหัสนี้ ...
C c = new C();
c.doSomething();
... ควรใช้วิธีไหน? หากไม่มีการชี้แจงเพิ่มเติมใด ๆ ก็เป็นไปไม่ได้ที่คอมไพเลอร์จะแก้ไขความคลุมเครือ
นอกจากการลบล้างแล้วปัญหาใหญ่อื่น ๆ ที่มีการสืบทอดหลายรายการคือโครงร่างของวัตถุทางกายภาพในหน่วยความจำ
ภาษาเช่น C ++ และ Java และ C # สร้างโครงร่างตามที่อยู่คงที่สำหรับวัตถุแต่ละประเภท สิ่งนี้:
class A:
at offset 0 ... "abc" ... 4 byte int field
at offset 4 ... "xyz" ... 8 byte double field
at offset 12 ... "speak" ... 4 byte function pointer
class B:
at offset 0 ... "foo" ... 2 byte short field
at offset 2 ... 2 bytes of alignment padding
at offset 4 ... "bar" ... 4 byte array pointer
at offset 8 ... "baz" ... 4 byte function pointer
เมื่อคอมไพลเลอร์สร้างรหัสเครื่อง (หรือ bytecode) จะใช้การชดเชยตัวเลขเหล่านั้นเพื่อเข้าถึงแต่ละเมธอดหรือฟิลด์
การสืบทอดหลายรายการทำให้ยุ่งยากมาก
ถ้าคลาสC
สืบทอดจากทั้งสองA
และB
คอมไพลเลอร์จะต้องตัดสินใจว่าจะจัดวางข้อมูลAB
ตามลำดับหรือBA
ตามลำดับ
แต่ตอนนี้ลองนึกดูว่าคุณกำลังเรียกใช้เมธอดบนB
วัตถุ มันเป็นเพียงแค่B
? หรือจริงๆแล้วมันเป็นC
วัตถุที่เรียกว่า polymorphically ผ่านของมันB
อินเทอร์เฟซ ? รูปแบบทางกายภาพจะแตกต่างกันและเป็นไปไม่ได้ที่จะทราบค่าชดเชยของฟังก์ชันที่จะเรียกใช้ที่ไซต์เรียกใช้ทั้งนี้ขึ้นอยู่กับข้อมูลประจำตัวจริงของวัตถุ
วิธีจัดการระบบประเภทนี้คือการทิ้งแนวทางโครงร่างคงที่โดยอนุญาตให้แต่ละอ็อบเจ็กต์ถูกสอบถามสำหรับเลย์เอาต์ก่อนที่จะพยายามเรียกใช้ฟังก์ชันหรือเข้าถึงฟิลด์ของมัน
ดังนั้น ... เรื่องสั้นสั้น ๆ ... มันปวดคอสำหรับผู้เขียนคอมไพเลอร์เพื่อรองรับการสืบทอดหลาย ๆ ดังนั้นเมื่อมีคนอย่าง Guido van Rossum ออกแบบ python หรือเมื่อ Anders Hejlsberg ออกแบบ c # พวกเขารู้ว่าการสนับสนุนการสืบทอดหลาย ๆ อย่างจะทำให้การใช้งานคอมไพเลอร์ซับซ้อนขึ้นอย่างมากและพวกเขาไม่คิดว่าผลประโยชน์จะคุ้มค่ากับต้นทุน
ปัญหาที่พวกคุณพูดถึงไม่ใช่เรื่องยากที่จะแก้ไข ในความเป็นจริงเช่น Eiffel ทำได้ดีมาก! (และไม่แนะนำตัวเลือกตามอำเภอใจหรืออะไรก็ตาม)
เช่นถ้าคุณสืบทอดจาก A และ B ทั้งที่มี method foo () แน่นอนว่าคุณไม่ต้องการตัวเลือกโดยพลการในคลาส C ของคุณที่สืบทอดจากทั้ง A และ B คุณต้องกำหนด foo ใหม่ดังนั้นจึงชัดเจนว่าจะเป็นอย่างไร ใช้ถ้าเรียก c.foo () หรือมิฉะนั้นคุณต้องเปลี่ยนชื่อหนึ่งในวิธีการใน C. (อาจกลายเป็น bar ())
นอกจากนี้ฉันคิดว่าการสืบทอดหลาย ๆ ครั้งมักจะมีประโยชน์มาก หากคุณดูที่ไลบรารีของ Eiffel คุณจะเห็นว่ามีการใช้งานทั่วทุกที่และโดยส่วนตัวแล้วฉันพลาดฟีเจอร์นี้เมื่อต้องกลับไปเขียนโปรแกรมใน Java
ความคลุมเครือที่เกิดขึ้นเมื่อสองคลาส B และ C สืบทอดมาจาก A และคลาส D สืบทอดจากทั้ง B และ C หากมีวิธีการใน A ที่ B และ C ได้แทนที่และ D ไม่ได้แทนที่มันแล้วเวอร์ชันของ วิธี D สืบทอด: ของ B หรือของ C?
... เรียกว่า "ปัญหาเพชร" เพราะรูปร่างของแผนภาพการสืบทอดคลาสในสถานการณ์นี้ ในกรณีนี้คลาส A จะอยู่ด้านบนทั้ง B และ C แยกกันอยู่ข้างใต้และ D รวมทั้งสองเข้าด้วยกันที่ด้านล่างเพื่อสร้างรูปทรงเพชร ...
someZ
และต้องการที่จะส่งไปObject
ยังB
? ซึ่งB
จะได้รับ?
Object
ยังประเภทนั้น ...
การสืบทอดหลายรายการเป็นหนึ่งในสิ่งเหล่านั้นที่ไม่ได้ใช้บ่อยและสามารถนำไปใช้ในทางที่ผิดได้ แต่บางครั้งก็จำเป็น
ฉันไม่เคยเข้าใจที่จะไม่เพิ่มคุณสมบัติเพียงเพราะอาจถูกใช้ในทางที่ผิดเมื่อไม่มีทางเลือกที่ดี อินเทอร์เฟซไม่ใช่ทางเลือกสำหรับการสืบทอดหลายรายการ ประการแรกพวกเขาไม่อนุญาตให้คุณบังคับใช้เงื่อนไขเบื้องต้นหรือเงื่อนไขภายหลัง เช่นเดียวกับเครื่องมืออื่น ๆ คุณต้องรู้ว่าเมื่อใดเหมาะสมที่จะใช้และจะใช้อย่างไร
assert
ที่ไหน?
สมมติว่าคุณมีอ็อบเจกต์ A และ B ซึ่งทั้งคู่สืบทอดมาโดย C A และ B ทั้งคู่ใช้ foo () และ C ไม่ได้ ฉันเรียก C.foo () เลือกใช้งานใด มีปัญหาอื่น ๆ แต่ประเภทนี้เป็นเรื่องใหญ่
ปัญหาหลักของการสืบทอดหลายรายการสรุปได้อย่างดีกับตัวอย่างของ tloach เมื่อรับช่วงจากคลาสพื้นฐานหลายคลาสที่ใช้ฟังก์ชันหรือฟิลด์เดียวกันคอมไพเลอร์จะต้องตัดสินใจว่าจะนำไปใช้งานใด
สิ่งนี้จะแย่ลงเมื่อคุณสืบทอดจากหลายคลาสที่สืบทอดมาจากคลาสพื้นฐานเดียวกัน (มรดกเพชรถ้าคุณวาดต้นไม้มรดกคุณจะได้รูปเพชร)
ปัญหาเหล่านี้ไม่ใช่ปัญหาสำหรับคอมไพเลอร์ที่จะเอาชนะ แต่ทางเลือกที่คอมไพเลอร์ต้องทำในที่นี้ค่อนข้างจะเป็นไปตามอำเภอใจซึ่งทำให้โค้ดใช้งานง่ายน้อยลง
ฉันพบว่าเมื่อทำการออกแบบ OO ที่ดีฉันไม่ต้องการการสืบทอดหลาย ๆ ในกรณีที่ฉันต้องการฉันมักจะพบว่าฉันใช้การสืบทอดเพื่อใช้ฟังก์ชันการทำงานซ้ำในขณะที่การสืบทอดนั้นเหมาะสมสำหรับความสัมพันธ์แบบ "is-a" เท่านั้น
มีเทคนิคอื่น ๆ เช่น mixins ที่แก้ปัญหาเดียวกันและไม่มีปัญหาในการสืบทอดหลาย ๆ
([..bool..]? "test": 1)
อะไร?
ฉันไม่คิดว่าปัญหาเพชรเป็นปัญหาฉันคิดว่าซับซ้อนไม่มีอะไรอื่น
ปัญหาที่เลวร้ายที่สุดจากมุมมองของฉันที่มีการสืบทอดหลายอย่างคือ RAD - เหยื่อและคนที่อ้างตัวว่าเป็นนักพัฒนา แต่ในความเป็นจริงนั้นติดอยู่กับความรู้เพียงครึ่งเดียว (อย่างดีที่สุด)
โดยส่วนตัวแล้วฉันจะมีความสุขมากถ้าในที่สุดฉันสามารถทำบางอย่างใน Windows Forms เช่นนี้ได้ (ไม่ใช่รหัสที่ถูกต้อง แต่ควรให้แนวคิดแก่คุณ):
public sealed class CustomerEditView : Form, MVCView<Customer>
นี่เป็นปัญหาหลักที่ฉันมีกับการไม่มีมรดกหลายรายการ คุณสามารถทำสิ่งที่คล้ายกันกับอินเทอร์เฟซ แต่มีสิ่งที่ฉันเรียกว่า "s *** code" นี่คือ c *** ซ้ำซากที่เจ็บปวดที่คุณต้องเขียนในแต่ละชั้นเรียนของคุณเพื่อให้ได้บริบทข้อมูลเช่น
ในความคิดของฉันไม่ควรมีความจำเป็นอย่างยิ่งสำหรับการทำโค้ดซ้ำ ๆ ในภาษาสมัยใหม่
Common Lisp Object System (CLOS) เป็นอีกตัวอย่างหนึ่งของสิ่งที่รองรับ MI ในขณะที่หลีกเลี่ยงปัญหาสไตล์ C ++: การสืบทอดจะได้รับค่าเริ่มต้นที่สมเหตุสมผลในขณะที่ยังให้คุณมีอิสระในการตัดสินใจอย่างชัดเจนว่าจะพูดอย่างไรเรียกพฤติกรรมของ super .
ไม่มีอะไรผิดในการสืบทอดหลาย ๆ อย่างเอง ปัญหาคือการเพิ่มการสืบทอดหลายรายการให้กับภาษาที่ไม่ได้ออกแบบโดยคำนึงถึงการสืบทอดหลายรายการตั้งแต่เริ่มต้น
ภาษา Eiffel รองรับการสืบทอดหลายรายการโดยไม่มีข้อ จำกัด ด้วยวิธีที่มีประสิทธิภาพและประสิทธิผล แต่ภาษาได้รับการออกแบบตั้งแต่เริ่มต้นเพื่อรองรับ
คุณลักษณะนี้มีความซับซ้อนในการใช้งานสำหรับนักพัฒนาคอมไพเลอร์ แต่ดูเหมือนว่าข้อเสียเปรียบอาจได้รับการชดเชยด้วยข้อเท็จจริงที่ว่าการสนับสนุนการสืบทอดหลายรายการที่ดีสามารถหลีกเลี่ยงการสนับสนุนคุณสมบัติอื่น ๆ (เช่นไม่จำเป็นต้องใช้ส่วนต่อประสานหรือวิธีการขยาย)
ฉันคิดว่าการสนับสนุนหลายมรดกหรือไม่เป็นเรื่องของการเลือกมากกว่าเป็นเรื่องของลำดับความสำคัญ คุณลักษณะที่ซับซ้อนกว่านั้นต้องใช้เวลามากกว่าในการนำไปใช้และปฏิบัติอย่างถูกต้องและอาจมีการโต้เถียงกันมากขึ้น การใช้งาน C ++ อาจเป็นสาเหตุที่ไม่ได้ใช้การสืบทอดหลายรายการใน C # และ Java ...
เป้าหมายการออกแบบอย่างหนึ่งของเฟรมเวิร์กเช่น Java และ. NET คือทำให้โค้ดที่คอมไพล์ทำงานกับไลบรารีที่คอมไพล์ไว้ล่วงหน้าเวอร์ชันหนึ่งทำงานได้ดีเท่าเทียมกันกับไลบรารีเวอร์ชันที่ตามมาแม้ว่าเวอร์ชันที่ตามมานั้น เพิ่มคุณสมบัติใหม่ ในขณะที่กระบวนทัศน์ปกติในภาษาเช่น C หรือ C ++ คือการแจกจ่ายไฟล์ปฏิบัติการที่เชื่อมโยงแบบสแตติกซึ่งมีไลบรารีทั้งหมดที่ต้องการ แต่กระบวนทัศน์ใน. NET และ Java คือการแจกจ่ายแอปพลิเคชันเป็นคอลเลกชันของส่วนประกอบที่ "เชื่อมโยง" ในขณะทำงาน .
รูปแบบ COM ที่นำหน้า. NET พยายามใช้วิธีการทั่วไปนี้ แต่ไม่มีการสืบทอดจริง ๆ แต่คำจำกัดความของแต่ละคลาสจะกำหนดทั้งคลาสและอินเทอร์เฟซที่มีชื่อเดียวกันอย่างมีประสิทธิภาพซึ่งมีสมาชิกสาธารณะทั้งหมด อินสแตนซ์เป็นประเภทคลาสในขณะที่การอ้างอิงเป็นประเภทอินเทอร์เฟซ การประกาศคลาสเป็นที่มาจากคลาสอื่นเทียบเท่ากับการประกาศคลาสเป็นการใช้อินเทอร์เฟซของอีกฝ่ายและต้องการให้คลาสใหม่นำสมาชิกสาธารณะทั้งหมดของคลาสที่คลาสใหม่มาใช้ ถ้า Y และ Z มาจาก X แล้ว W มาจาก Y และ Z ก็จะไม่สำคัญว่า Y และ Z จะใช้สมาชิกของ X แตกต่างกันหรือไม่เพราะ Z จะไม่สามารถใช้การนำไปใช้งานได้ - มันจะต้องกำหนด ด้วยตัวเอง W อาจห่อหุ้มอินสแตนซ์ของ Y และ / หรือ Z
ความยากลำบากใน Java และ. NET คือรหัสได้รับอนุญาตให้สืบทอดสมาชิกและเข้าถึงได้โดยปริยายหมายถึงสมาชิกหลัก สมมติว่ามีคลาส WZ ที่เกี่ยวข้องดังนี้:
class X { public virtual void Foo() { Console.WriteLine("XFoo"); }
class Y : X {};
class Z : X {};
class W : Y, Z // Not actually permitted in C#
{
public static void Test()
{
var it = new W();
it.Foo();
}
}
ดูเหมือนว่าW.Test()
ควรสร้างอินสแตนซ์ของ W เรียกใช้วิธีการเสมือนที่Foo
กำหนดไว้ในX
. อย่างไรก็ตามสมมติว่า Y และ Z อยู่ในโมดูลที่คอมไพล์แยกกันและแม้ว่าจะถูกกำหนดไว้ข้างต้นเมื่อรวบรวม X และ W แต่ก็มีการเปลี่ยนแปลงและคอมไพล์ใหม่ในภายหลัง:
class Y : X { public override void Foo() { Console.WriteLine("YFoo"); }
class Z : X { public override void Foo() { Console.WriteLine("ZFoo"); }
ตอนนี้ผลของการโทรควรเป็นอย่างไร แต่จนกว่าผู้ใช้จะพยายามเรียกใช้ W ด้วย Y และ Z เวอร์ชันใหม่จะไม่มีทางที่ส่วนใดของระบบจะรับรู้ได้ว่ามีปัญหา (เว้นแต่ว่า W จะถือว่าผิดกฎหมายด้วยซ้ำ ก่อนการเปลี่ยนแปลงเป็น Y และ Z)W.Test()
อย่างไร? หากโปรแกรมต้องเชื่อมโยงแบบคงที่ก่อนการแจกจ่ายสเตจลิงก์แบบคงที่อาจสามารถแยกแยะได้ว่าในขณะที่โปรแกรมไม่มีความคลุมเครือก่อนที่จะเปลี่ยน Y และ Z แต่การเปลี่ยนแปลง Y และ Z ทำให้สิ่งต่าง ๆ ไม่ชัดเจนและตัวเชื่อมโยงสามารถปฏิเสธที่จะ สร้างโปรแกรมเว้นแต่หรือจนกว่าความไม่ชัดเจนดังกล่าวจะได้รับการแก้ไข ในทางกลับกันเป็นไปได้ว่าบุคคลที่มีทั้ง W และ Y และ Z เวอร์ชันใหม่คือคนที่ต้องการเรียกใช้โปรแกรมและไม่มีซอร์สโค้ดสำหรับโปรแกรมใด ๆ เมื่อW.Test()
วิ่งมันจะไม่มีอะไรชัดเจนอีกต่อไปW.Test()
เพชรไม่ใช่ปัญหาตราบใดที่คุณไม่ได้ใช้อะไรเลยเช่นการสืบทอดเสมือนของ C ++: ในการสืบทอดปกติแต่ละคลาสพื้นฐานจะมีลักษณะคล้ายกับฟิลด์สมาชิก (จริงๆแล้วพวกมันถูกจัดวางใน RAM ด้วยวิธีนี้) ทำให้คุณมีน้ำตาลวากยสัมพันธ์และ ความสามารถพิเศษในการแทนที่วิธีการเสมือนจริงเพิ่มเติม นั่นอาจทำให้เกิดความคลุมเครือบางอย่างในเวลาคอมไพล์ แต่มักจะแก้ได้ง่าย
ในทางกลับกันด้วยการสืบทอดเสมือนมันง่ายเกินไปที่จะควบคุมไม่ได้ (และกลายเป็นเรื่องยุ่งเหยิง) ลองพิจารณาแผนภาพ "หัวใจ" เป็นตัวอย่าง:
A A
/ \ / \
B C D E
\ / \ /
F G
\ /
H
ใน C ++ เป็นไปไม่ได้เลย: ทันทีที่F
และG
รวมเข้าเป็นคลาสเดียวA
s ของพวกเขาจะถูกรวมเข้าด้วยกันเช่นกัน ซึ่งหมายความว่าคุณอาจไม่เคยพิจารณาฐานเรียนทึบแสงใน C ++ (ในตัวอย่างนี้คุณจะต้องสร้างA
ในH
เพื่อให้คุณได้รู้ว่ามันอยู่ที่ไหนสักแห่งในปัจจุบันในลำดับชั้น) อย่างไรก็ตามในภาษาอื่นอาจใช้งานได้ ตัวอย่างเช่นF
และG
สามารถประกาศ A อย่างชัดเจนว่าเป็น "ภายใน" ดังนั้นจึงห้ามไม่ให้มีการรวมผลที่ตามมาและทำให้ตัวเองเป็นของแข็งอย่างมีประสิทธิภาพ
อีกตัวอย่างที่น่าสนใจ ( ไม่ใช่เฉพาะ C ++):
A
/ \
B B
| |
C D
\ /
E
ที่นี่B
ใช้การสืบทอดเสมือนเท่านั้น ดังนั้นE
มีสองB
s A
ที่ใช้ร่วมกันเดียวกัน ด้วยวิธีนี้คุณจะได้A*
ตัวชี้ที่ชี้ไปE
แต่คุณไม่สามารถส่งไปยังB*
ตัวชี้แม้ว่าวัตถุนั้น ๆเป็นจริงB
เป็นนักแสดงดังกล่าวเป็นที่คลุมเครือและความคลุมเครือนี้ไม่สามารถตรวจพบที่รวบรวมเวลา (ยกเว้นกรณีที่คอมไพเลอร์เห็น โปรแกรมทั้งหมด) นี่คือรหัสทดสอบ:
struct A { virtual ~A() {} /* so that the class is polymorphic */ };
struct B: virtual A {};
struct C: B {};
struct D: B {};
struct E: C, D {};
int main() {
E data;
E *e = &data;
A *a = dynamic_cast<A *>(e); // works, A is unambiguous
// B *b = dynamic_cast<B *>(e); // doesn't compile
B *b = dynamic_cast<B *>(a); // NULL: B is ambiguous
std::cout << "E: " << e << std::endl;
std::cout << "A: " << a << std::endl;
std::cout << "B: " << b << std::endl;
// the next casts work
std::cout << "A::C::B: " << dynamic_cast<B *>(dynamic_cast<C *>(e)) << std::endl;
std::cout << "A::D::B: " << dynamic_cast<B *>(dynamic_cast<D *>(e)) << std::endl;
std::cout << "A=>C=>B: " << dynamic_cast<B *>(dynamic_cast<C *>(a)) << std::endl;
std::cout << "A=>D=>B: " << dynamic_cast<B *>(dynamic_cast<D *>(a)) << std::endl;
return 0;
}
นอกจากนี้การนำไปใช้งานอาจมีความซับซ้อนมาก (ขึ้นอยู่กับภาษาดูคำตอบของ benjismith)