การกำหนดว่าวิธีใดสามารถแทนที่ข้อผูกพันที่แข็งแกร่งกว่าการกำหนดวิธีการที่สามารถเรียกได้


36

จาก: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: ฉันยังคงคิดว่ามันเป็นความจริงแม้หลังจากผ่านไปสิบปี การสืบทอดเป็นวิธีที่ยอดเยี่ยมในการเปลี่ยนพฤติกรรม แต่เรารู้ว่ามันเปราะบางเนื่องจากคลาสย่อยสามารถตั้งสมมติฐานเกี่ยวกับบริบทที่มีการเรียกเมธอดแทนที่ได้ง่าย มีการเชื่อมต่ออย่างแน่นหนาระหว่างคลาสฐานและคลาสย่อยเนื่องจากบริบทโดยปริยายซึ่งโค้ดคลาสย่อยที่ฉันเสียบเข้าจะถูกเรียก องค์ประกอบมีคุณสมบัติที่ดีกว่า การมีเพศสัมพันธ์จะลดลงเพียงแค่มีสิ่งเล็ก ๆ ที่คุณเสียบเข้าไปกับสิ่งที่ใหญ่กว่าและวัตถุที่ใหญ่กว่าก็เรียกวัตถุที่เล็กกว่ากลับมา จากมุมมองของ API ที่กำหนดว่าวิธีการใด ๆ ที่สามารถแทนที่ได้คือความมุ่งมั่นที่แข็งแกร่งกว่าการกำหนดวิธีการที่สามารถเรียกได้

ฉันไม่เข้าใจสิ่งที่เขาหมายถึง ใครช่วยอธิบายหน่อยได้ไหม

คำตอบ:


63

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

ตัวอย่างเช่นหากคุณเผยแพร่วิธีการล้างข้อมูลคุณสามารถมั่นใจได้ว่าทรัพยากรจะถูกจัดสรรคืนอย่างเหมาะสมตราบใดที่ผู้ใช้อย่าลืมเรียกวิธีการนี้ว่าเป็นสิ่งสุดท้ายที่พวกเขาทำ แต่ถ้าวิธีการ overridable บางคนอาจจะแทนที่ในคลาสย่อยและไม่ได้superโทร ดังนั้นผู้ใช้คนที่สามอาจใช้คลาสนั้นและทำให้ทรัพยากรรั่วไหลแม้ว่าพวกเขาจะเรียกตามหน้าที่cleanup()ในตอนท้าย ! ซึ่งหมายความว่าคุณจะไม่สามารถรับประกันความหมายของรหัสของคุณได้อีกต่อไปซึ่งเป็นสิ่งที่แย่มาก

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


11
นี่อาจเป็นข้อโต้แย้งที่ดีที่สุดสำหรับมรดกที่ฉันเคยอ่าน จากสาเหตุทั้งหมดที่ฉันพบฉันไม่เคยเจอข้อโต้แย้งทั้งสองนี้มาก่อน (การมีเพศสัมพันธ์และการทำลายการทำงานผ่านการเอาชนะ) แต่ทั้งคู่เป็นข้อโต้แย้งที่ทรงพลังมากต่อการสืบทอด
David Arno

5
@DavidArno ฉันไม่คิดว่ามันจะเป็นการโต้แย้งการสืบทอด ฉันคิดว่ามันเป็นข้อโต้แย้งในการ "ทำให้ทุกอย่างเหนือชั้นโดยค่าเริ่มต้น" การรับมรดกนั้นไม่ได้อันตรายโดยตัวมันเอง
svick

15
ในขณะที่ฟังดูเหมือนเป็นจุดที่ดีฉันไม่สามารถเห็นได้อย่างชัดเจนว่า "ผู้ใช้อาจเพิ่มรหัสรถบั๊กกี้ของเขา" เป็นอาร์กิวเมนต์ได้อย่างไร การเปิดใช้งานการสืบทอดทำให้ผู้ใช้สามารถเพิ่มฟังก์ชั่นที่ขาดได้โดยไม่สูญเสียความสามารถในการอัปเดตซึ่งเป็นมาตรการที่สามารถป้องกันและแก้ไขข้อบกพร่องได้ หากรหัสผู้ใช้ด้านบนของ API ของคุณเสียนั่นไม่ใช่ข้อบกพร่องของ API
Sebb

4
คุณสามารถเปลี่ยนให้อาร์กิวเมนต์นั้นกลายเป็นเรื่องง่าย: ตัวเข้ารหัสแรกสร้างอาร์กิวเมนต์การล้างข้อมูล แต่ทำผิดพลาดและไม่ล้างทุกอย่าง coder ตัวที่สองจะแทนที่วิธีการล้างข้อมูลและทำงานได้ดีและ coder # 3 ใช้คลาสและไม่มีการรั่วไหลของทรัพยากรแม้ว่า coder # 1 จะทำให้เกิดความยุ่งเหยิง
Pieter B

6
@oval แน่นอน นี่คือเหตุผลว่าทำไมการสืบมรดกจึงเป็นบทเรียนอันดับหนึ่งในหนังสือ OOP และชั้นเรียนเบื้องต้นทุกเล่ม
Kevin Krumwiede

30

หากคุณเผยแพร่ฟังก์ชั่นปกติคุณให้สัญญาด้านเดียว:
ฟังก์ชั่นทำอะไรถ้าเรียกว่า?

หากคุณเผยแพร่การโทรกลับคุณจะต้องทำสัญญาด้านเดียว:
เมื่อไหร่และจะมีการเรียกอย่างไร

และถ้าคุณเผยแพร่ฟังก์ชั่น overridable มันทั้งสองพร้อมกันดังนั้นคุณให้สัญญาสองด้าน:
มันจะถูกเรียกเมื่อใดและจะต้องทำอย่างไรถ้าถูกเรียก?

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

ตัวอย่างของ reneging ดังกล่าวสัญญาสองด้านคือย้ายจากshowและhideไปsetVisible(boolean)ในjava.awt.Component


+1 ฉันไม่แน่ใจว่าทำไมคำตอบอื่น ๆ จึงเป็นที่ยอมรับ มันทำให้บางประเด็นที่น่าสนใจ แต่ก็ไม่ใช่คำตอบที่ถูกต้องสำหรับคำถามนี้เพราะมันไม่ใช่สิ่งที่สื่อความหมายอ้างถึง
ruakh

นี่เป็นคำตอบที่ถูกต้อง แต่ฉันไม่เข้าใจตัวอย่าง การแทนที่การแสดงและซ่อนด้วย setVisible (บูลีน) ดูเหมือนว่าจะทำลายโค้ดที่ไม่ได้ใช้การสืบทอด ฉันพลาดอะไรไปรึเปล่า?
eigensheep

3
@eigensheep: showและยังคงอยู่พวกเขากำลังเพียงhide @Deprecatedดังนั้นการเปลี่ยนแปลงจะไม่ทำลายโค้ดใด ๆ ที่เรียกใช้พวกเขา แต่หากคุณแทนที่พวกเขาแล้วการแทนที่ของคุณจะไม่ถูกเรียกโดยลูกค้าที่ย้ายไปยัง 'setVisible' ใหม่ (ผมไม่เคยใช้สวิงดังนั้นผมจึงไม่ทราบว่ามันเป็นเรื่องธรรมดาที่จะแทนที่เหล่านั้น แต่เนื่องจากมันเกิดขึ้นเป็นเวลานานที่ผ่านมาผมคิดว่าเหตุผลที่ Deduplicator จำได้ก็คือว่ามันกัดเขา / เธอเจ็บปวด.)
ruakh

12

คำตอบของ Kilian Foth นั้นยอดเยี่ยม ฉันแค่ต้องการเพิ่มตัวอย่างตามบัญญัติ * ของสาเหตุที่เป็นปัญหา ลองนึกภาพคลาสพอยต์จำนวนเต็ม:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

ทีนี้ลองซับคลาสให้เป็นจุด 3 มิติ

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

ง่ายสุด ๆ ! ลองใช้คะแนนของเรา:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

คุณอาจสงสัยว่าทำไมฉันโพสต์ตัวอย่างง่าย ๆ นี่คือการจับ:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

เมื่อเราเปรียบเทียบจุด 2D กับจุด 3D ที่เทียบเท่าเราจะได้รับจริง แต่เมื่อเราย้อนกลับการเปรียบเทียบเราจะได้รับเท็จ (เนื่องจาก p2a ล้มเหลวinstanceof Point3D)

ข้อสรุป

  1. โดยทั่วไปแล้วเป็นไปได้ที่จะใช้วิธีการในคลาสย่อยในลักษณะที่ไม่เข้ากันได้กับวิธีการที่ซูเปอร์คลาสคาดว่ามันจะทำงานได้อีกต่อไป

  2. โดยทั่วไปแล้วจะไม่สามารถใช้เท่ากับ () ในคลาสย่อยที่แตกต่างกันอย่างมีนัยสำคัญในวิธีที่เข้ากันได้กับคลาสแม่

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

ตัวอย่างที่ดีของการทำสัญญาที่ดีสะกดออกมาเป็นตัวเปรียบเทียบ เพียงเพิกเฉยสิ่งที่พูดเกี่ยวกับ.equals()สาเหตุที่อธิบายไว้ข้างต้น นี่คือตัวอย่างของวิธีการเปรียบเทียบสามารถทำสิ่ง.equals()ไม่สามารถ

หมายเหตุ

  1. "Effective Java" ของ Josh Bloch รายการที่ 8 เป็นที่มาของตัวอย่างนี้ แต่ Bloch ใช้ ColorPoint ซึ่งเพิ่มสีแทนแกนที่สามและใช้ double เป็นคู่แทน ints ตัวอย่าง Java ของ Bloch นั้นมีการทำซ้ำโดยOdersky / Spoon / Vennersซึ่งทำให้ตัวอย่างของพวกเขาพร้อมใช้งานออนไลน์

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

โบนัส

เครื่องมือเปรียบเทียบยังน่าสนใจเพราะสามารถแก้ไขปัญหาของการใช้งานเท่ากับ () ได้อย่างถูกต้อง ยังดีกว่าตามรูปแบบสำหรับการแก้ไขปัญหาการสืบทอดประเภทนี้: รูปแบบการออกแบบกลยุทธ์ ประเภท Typeclasses ที่คน Haskell และ Scala รู้สึกตื่นเต้นก็เป็นกลยุทธ์เช่นกัน การรับมรดกไม่ได้เลวหรือผิดมันเป็นเรื่องยุ่งยาก สำหรับการอ่านเพิ่มเติมให้ดูที่กระดาษของ Philip Wadler วิธีทำให้ความแตกต่าง ad-hoc เฉพาะกิจน้อยลง


1
SortedMap และ SortedSet ไม่ได้เปลี่ยนคำจำกัดความequalsจากวิธีการที่ Map และ Set กำหนดไว้ ความเท่าเทียมกันละเว้นการสั่งซื้ออย่างสมบูรณ์โดยมีเอฟเฟกต์เช่น SortedSets สองชุดที่มีองค์ประกอบเดียวกัน แต่ลำดับการเรียงที่แตกต่างกันยังคงเปรียบเทียบเท่ากัน
user2357112 รองรับ Monica

1
@ user2357112 คุณพูดถูกและฉันลบตัวอย่างนั้นแล้ว SortedMap.equals () การเข้ากันได้กับแผนที่เป็นปัญหาที่แยกต่างหากซึ่งฉันจะดำเนินการต่อเกี่ยวกับการร้องเรียน SortedMap โดยทั่วไปคือ O (log2 n) และ HashMap (การสอดแทรกมาตรฐานของแผนที่) คือ O (1) ดังนั้นคุณจะใช้ SortedMap ถ้าคุณจริงๆดูแลเกี่ยวกับการสั่งซื้อ ด้วยเหตุนี้ฉันเชื่อว่าคำสั่งซื้อมีความสำคัญพอที่จะเป็นองค์ประกอบที่สำคัญของการทดสอบเท่ากับ () ในการใช้งาน SortedMap พวกเขาไม่ควรแบ่งปันการใช้งานเท่ากับ () กับแผนที่ (พวกเขาทำผ่าน AbstractMap ใน Java)
GlenPeterson

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

7
นี่เป็นตัวอย่างที่น่ากลัวอย่างเกลน คุณเพิ่งใช้การสืบทอดในวิธีที่ไม่ควรใช้ไม่น่าแปลกใจที่คลาสไม่ทำงานตามที่คุณต้องการ คุณทำลายหลักการทดแทนของ Liskov ด้วยการให้นามธรรมที่ไม่ถูกต้อง (จุด 2 มิติ) แต่เพียงเพราะมรดกไม่ดีในตัวอย่างที่ไม่ถูกต้องของคุณไม่ได้หมายความว่ามันไม่ดีโดยทั่วไป แม้ว่าคำตอบนี้อาจดูสมเหตุสมผล แต่จะสร้างความสับสนให้กับผู้ที่ไม่ทราบว่ามันละเมิดกฎการสืบทอดขั้นพื้นฐานที่สุด
Andy

3
ELI5 ของหลักการ Substituton ของ Liskov กล่าวว่า: หากคลาสBเป็นเด็กของคลาสAและหากคุณสร้างอินสแตนซ์ของวัตถุในคลาสBคุณควรจะสามารถโยนBวัตถุคลาสให้กับแม่ของมันและใช้ API ของตัวแปรที่ได้รับการคัดเลือก เด็ก. คุณทำผิดกฎโดยระบุคุณสมบัติที่สาม คุณวางแผนที่จะเข้าถึงzพิกัดหลังจากที่คุณส่งPoint3DตัวแปรไปPoint2Dเมื่อคลาสฐานไม่มีความคิดคุณสมบัติดังกล่าวอยู่? หากการส่งคลาสเด็กไปที่ฐานของคุณจะทำให้ API สาธารณะแตกหักสิ่งที่คุณทำผิด
Andy

4

การห่อหุ้มนั้นอ่อนแอลง

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

ด้านล่างเป็นตัวอย่างจริง (ย่อมาจาก) จากกรอบงานคอลเลกชัน java ความอนุเคราะห์ของ Peter Norvig ( http://norvig.com/java-iaq.html )

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

แล้วจะเกิดอะไรขึ้นถ้าเราซับคลาสนี้

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

เรามีข้อผิดพลาด: บางครั้งเราเพิ่ม "dog" และ hashtable จะได้รับรายการสำหรับ "dogss" สาเหตุคือมีคนที่นำการติดตั้งไปใช้ซึ่งคนที่ออกแบบคลาส Hashtable ไม่ได้คาดหวัง

การแบ่งความสามารถในการสืบทอด

หากคุณอนุญาตให้คลาสของคุณเป็นคลาสย่อยคุณจะต้องเพิ่มเมธอดใด ๆ ในคลาสของคุณ สิ่งนี้สามารถทำได้โดยไม่ทำลายอะไรเลย

เมื่อคุณเพิ่มวิธีการใหม่ในอินเทอร์เฟซทุกคนที่สืบทอดมาจากคลาสของคุณจะต้องใช้วิธีการเหล่านั้น


3

หากมีการเรียกใช้เมธอดคุณจะต้องตรวจสอบให้แน่ใจว่าวิธีการทำงานนั้นถูกต้อง แค่นั้นแหละ. เสร็จสิ้น

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

ดังนั้นผู้สร้างวิธีการผู้ปกครองจำเป็นต้องตั้งสมมติฐานเกี่ยวกับวิธีการที่ชั้นเรียนและวิธีการอาจถูกแทนที่ในอนาคต

อย่างไรก็ตามผู้เขียนกำลังพูดถึงปัญหาที่แตกต่างในข้อความที่ยกมา:

แต่เรารู้ว่ามันเปราะบางเนื่องจากคลาสย่อยสามารถตั้งสมมติฐานเกี่ยวกับบริบทที่มีการเรียกเมธอดแทนที่ได้ง่าย

พิจารณาวิธีการaที่จะถูกเรียกว่าปกติจากวิธีแต่ในบางกรณีที่หายากและไม่เห็นได้ชัดจากวิธีการb cหากผู้เขียนของวิธีการเอาชนะมองเห็นcวิธีการและความคาดหวังaของมันก็เป็นที่ชัดเจนว่าสิ่งต่าง ๆ สามารถผิดไป

ดังนั้นจึงมีความสำคัญมากกว่าที่aกำหนดไว้อย่างชัดเจนและไม่น่าสงสัยจัดทำเอกสารอย่างดี "ทำสิ่งหนึ่งและทำได้ดี" - มากกว่าดังนั้นหากเป็นวิธีที่ออกแบบมาเพื่อให้เรียกเท่านั้น

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.