Liskov Substitution Principle ist unbequem



  • Hallo Freunde.

    In diesem Artikel wird das Liskov Substitution Principle beschrieben. Das laesst sich wie folgt zusammenfassen (aus http://www.eventhelix.com/RealtimeMantra/Object_Oriented/liskov_substitution_principle.htm geklaut):

    In class hierarchies, it should be possible to treat a specialized object as if it were a base class object.

    Ein Beispiel zur Veranschaulichung:

    void f(Base* b) {
        //use b
    }
    

    Laut LSP soll es moeglich sein, den Parametertyp Base durch einen beliebigen, von Base abgeiteten, Datentyp zu ersetzen, ohne dass das Unerwartete passiert (beispielsweise eine Exception geworfen wird).

    Ich verstehe zwar diese Problematik, die LSP zu vermeiden versucht, aber bei genauerer Betrachtung erscheint mir die Einhaltung sehr unbequem.

    Ich erklaere mein Problem mal an einem anderen Beispiel: Ich habe ein paar abgeleitete Klassen, eine Hierarchie von vielen 'Datentypen' einer Datenbank. Es existiert eine abstrakte Basisklasse Data_type, von der sich andere Dinge ableiten, wie Zahlen, Texte und so:

    class Data_type {};
    
    class Number : public Data_type {};
    
    vieles mehr
    

    Soweit, sogut.
    Das Problem ist: Es soll eine allgemeine Tabelle geben (einspaltig), welche alle Datentypen aufnehmen kann. Weiterhin soll es eine Tablle geben, die *nur* Zahlen aufnehmen soll.

    class Table {
    public:
        virtual void insert(Data_type&)=0;
    };
    
    class Data_types_table : public Table {
    public:
        void insert(Data_type&);// gemaess LSP implementiert
    };
    
    Fortsetzung folgt
    

    Die rein virtuelle Table::insert-Funktion sagt anhand des Data_type&-Parameters eigentlich alles aus: "Fuege Datentypen ein. Also alle ohne Ausnahme". Das hat LSP gut rueber gebracht. Klienten, die eine Table-Referenz benutzen, erwarten, wenn sie einen Datentyp ueber diese Referenz einfuegen, dass der Datentyp auch wirklich eingefuegt wird, da diese Aussage quasi im Parametertyp dokumentiert ist. Das ist logisch und der Gedanke gefaellt mir.

    Aber das bringt meiner Meinung nach nicht nur Unbequemlichkeiten mit sich, sondern 'verunstaltet' auch das Design. Denn: Was ist eigentlich, wenn ich jetzt eine Tabelle einfuehren will, die nur Zahlen aufnimmt? Das wuerde dem LSP klar widersprechen:

    class Numbers_table : public Table { 
    public:
        void insert(Data_type& dt) {
            //pruefe, ob dt eine Zahl ist... wenn nicht, wirf eine Exception
            //sonst normal fortfahren
        }
    };
    

    Numbers_table::insert verhaelt sich wohl nicht so, wie Klienten das von einer Table::insert erwarten. Kein guter Stil?

    Der Autor da oben wuerde mein Problem in etwa so regeln:

    class Numbers_table { // man beachte die Unabhaengigkeit
    public:
        virtual void insert(Number&)=0; //keine Probleme mehr mit unerwartetem Verhalten
    };
    

    Das ist sogar eine viel besser Loesung, als sich mit bad_cast rumzuschlagen... wenn dadurch nur nicht das Problem mit der Zusammenfassung entstehen wuerde: Ich kann jetzt nicht mehr ohne weiteres schreiben, dass eine 'Datenbank' 'Tabellen' enthaelt. Nein, ich muss jetzt mindestens zwei Datenbanken haben, die jeweils 'normale Tabellen' (was sind aus der Sicht einer abstrakten Klasse eigentlich schon normale?*) und Tabellen mit Zahlen aufnehmen koennen. Nur eine einzige Datenbank haben, das ist mit diesem Design, dass auch Tabellen mit Zahlen unterztuetzen soll, nicht mehr moeglich. Und ueberhaupt: fuer jeden neuen Datentyp koennte ich eine neue Tabelle wollen, die eigens dazu da ist, um Objekte dieses Typs aufzunehmen. Aber fuer jede neue Tabelle eine neue Datebank? Das ist doch zum Kotzen, oder nicht?

    Ich kann etwas Albernes machen:

    class Abstract_table { 
        virtual ~Abstract_table()=0;
    //total leer, oder zumindest kein insert mehr, da insert(?)
    };
    
    und alle erben oeffentlich von Abstract_table
    

    Und dann doch eine, und nur eine, Datenbankklasse kreieren, um Typen von Abstract_table aufzunehmen, aber das nuetzt mir herzlich wenig, da keine grundlegende Funktionalitaet in Abstract_table festgelegt ist und ich sowieso nicht weiss, welcher tatsaechliche Typ dahinter steckt.

    * Gilt das LSP auch fuer abstrakte Klassen? Wenn ich das LSP benutze, moechte ich konsequent bleiben. Also gilt das LSP gefaelligst auch fuer abstrakte Klassen und das Problem besteht weiterhin. (Ich erwaehne diese Moeglichkeit nur, weil abstrakte Klassen, speziell Protokoll-Klassen sich von der Implementation trennen; eine Pre-condition liegt aber schon im Parametertyp fest bzw. ist die Bedingung selber, die, wenn eingehalten, die Funktion fein laufen laesst.)

    Was wenn Typen speziell dafuer entworfen wurden, den dynamischen Typ hinter einem Objekt zu ermitteln? Zum Beispiel der "Muellsortierer" aus "Thinking in C++ 2nd edition"? Der Sortierer ist dazu ausgelegt, verschiedene Trash-Sorten in geeignete Muelleimer zu sortieren, mittels RTTI. Entweder hat der Autor LSP ignoriert, oder absichtlich so entworfen? Oder er hatte keine Ahnung? Ich hab auch keine Ahnung, ist jetzt auch egal.

    Also, ich hoffe auf hilfreiche Antworten, um das aus der Welt zu schaffen.
    Gibt es eine bessere Loesung, als auf LSP zu verzichten oder viele Datenbanken zu haben?



  • meines Wissens sagt das lsp ganz klar aus: eine abgeleitete Klasse darf nie härtere Beschränkungen haben, als eine Basisklasse. Das ist sehr sinnvoll.

    hier mal ein anschauliches Beispiel:

    //Basisklasse
    class Rechteck{
        private:
            int x,y;
        public:
            Rechteck(int x,int y);
            virtual ~Rechteck();
            virtual void setX(int x);
            virtual void setY(int y);
    };
    
    //verwendung
    Rechteck* r=new Rechteck(3,4);
    r->setX(5);
    r->setY(4);
    

    Die Basisklasse sollte Klar sein. Nun ist aber auch ein Quadrat ein rechteck...leiten wir mal ab

    class Quadrat:public Rechteck{
        public:
            Quadrat(int x);//man braucht nurnoch eine seitenlänge
            virtual void setX(int x);//vererbt durch rechteck
            virtual void setY(int y);
    };
    Rechteck* r= new Quadrat(3,4);
    r->setX(5);
    r->setY(4);//und nu?
    

    wie du siehst, erlaubt rechteck, dass x und y unterschiedliche werte haben, während bei einem Quadrat beide werte gleich sein müssen. Im endeffekt darf man also Quadrat nicht von Rechteck erben lassen, weil das doof wird. Das Interface vereinbart gewisse anforderungen mit dem Benutzer, an die er sich zu halten hat-von den zusätzlichen vereinbarungen in der abgeleiteten Klassen kann er nichts wissen.

    nach dem LSP würde man wohl deinen Ansatz leicht verändern:

    class Data_type {
         public:
             //gibt an, welche art von daten gespeichert sind
             virtual int getType()=0;
    };
    class Table {
    public:
        virtual void insert(Data_type&)=0;
        //gibt an, welcher Datentyp gespeichert werden darf
        virtual int getType()=0;
    };
    

    nun sind die zusätzlichen beschränkungen teil der Basisklasse, und das LSP ist wieder erfüllt.



  • alle generalisierungen sind falsch.

    natürlich muß man auch mal abweichen, wenn das prinzip zu unpraktisch ist.



  • Aber nicht in diesem Fall. otze hat vollkommmen Recht.



  • Ringding schrieb:

    Aber nicht in diesem Fall. otze hat vollkommmen Recht.

    //gibt an, welcher Datentyp gespeichert werden darf 
        virtual int getType()=0;
    

    aber er hat mit nem schlimmeren designfehler sein prinzip verteidigt, als wo was der bruch des prinzips gebracht hätte. des fehler wird klar, wenn man versucht, solchige klasse zu verwenden und die switch-orgie einen in den wahnsinn treibet.

    ok, der fehler war vorgabe. "Es existiert eine abstrakte Basisklasse Data_type, von der sich andere Dinge ableiten, wie Zahlen, Texte und so". könnte sein, daß man nach dieser vorgabe einfach nicht mehr sinnvoll (im feierlichen c++-stil) reparieren kann.



  • volkard schrieb:

    Ringding schrieb:

    Aber nicht in diesem Fall. otze hat vollkommmen Recht.

    //gibt an, welcher Datentyp gespeichert werden darf 
        virtual int getType()=0;
    

    aber er hat mit nem schlimmeren designfehler sein prinzip verteidigt, als wo was der bruch des prinzips gebracht hätte. des fehler wird klar, wenn man versucht, solchige klasse zu verwenden und die switch-orgie einen in den wahnsinn treibet.

    wo siehst du da switch orgien? Die kommen doch erst rein, wenn du anhand von Data_type::getType herausfinden willst, in welche Tabelle etwas reingepackt werden muss. Aber an der Stelle hätte wohl sogut wie jede Lösung einen üblen knackpunkt.



  • Richtig übel wäre es ja erst wenn es keine Invarianz bei den Parametertypen geben würde.
    Wenn das LSP erfüllt sein soll müssten die Parameter eines Subtyps kontravariant zu denen des Supertyps sein.
    Dieses Verhalten ist aber unnatürlich.
    In nem aktuellen Kurs(in dem es allerdings über Design by Contract geht,welches aber dir gleichen Probleme bei Vererbung/Substituierbarkeit mit sich bringt)
    ist nen Beispiel mit ner parallelen Klassenhierarchie gegeben.
    Es gibt ne abstrakte Klasse Printer die eine Methode print hat,welche als Parameter ein Exemplar,der ebenfalls abstrakten Klasse,Document hat.
    Printer hat die konkreten Subtypen Plotter und LinePrinter.
    Document hat die konkreten Subtypen Text und Drawing.
    Plotter können nur Objekte vom Typ Drawing drucken und LinePrinter nur Text.
    Eine natürliche Modelierung wäre es jetzt wenn man die Parametertypen in Plotter und LinePrinter redefinieren würde.Damit würde man allerdings das LSP
    verletzen weil die Parametertypen sich ja höchstens in die allgemeinere Richtung entwickeln dürften.
    In C++ muss man sich darüber ja keine Gedanken machen.....gibt´s nich und fertig.
    Aber der arme Bertrand Meyer kann schon seit Jahren nicht richtig schlafen weil das Typsystem von Eiffel und sein Design by Contract Konzept sich ansich
    wiedersprechen 😃 ;).

    Ich denke da sollte man nicht allzu "wissenschaftlich" dran gehen und jedesmal für das konkrete Problem die angemessenste Lösung suchen.

    MfG Spacelord



  • Hallo,
    imo ist das hier die Ausprägung des klassischen "Ein Foo ist ein Ding, also ist eine Liste von Foo eine Liste von Dingen"-Denkfehlers. Das wirkt zwar auf den ersten Blick logisch, stimmt aber spätestens bei der Einfügeoperation nicht mehr. In eine Liste von Foo kann man nur Foos reintun. In eine Liste von Dingen aber auch Atom-U-Boote. Behandelst du nun eine Liste von Foo als Liste von Dingen, dann ist der dritte Weltkrieg wieder ein Stück näher.
    LSP sorgt hier nur dafür, dass der Denkfehler explizit wird. Imo ist das eine gute Sache und es wäre falsch, hier einfach LSP zu ignorieren.

    Eine Tabelle die nur Nummern aufnehmen kann ist nunmal keine Tabelle die alle Datentypen aufnehmen kann. Auch nicht, wenn Nummer eine Spezialisierung von Datentyp ist. Die Java-Leute lösen das mit ihren "bounded wildcard generics" (i.e. list<? extends Super> bzw. list<? super Super>). Was sich imo sehr nach "hack-hack-hack" anfühlt.

    In C++ würde ich mich erstmal fragen, ob Table (bzw. dessen insert-Methode) wirklich polymorph sein muss. Vielleicht reicht es ja, wenn Lesen polymorph ist.

    class Table {
    public:
        // Methoden außer insert
    };
    
    template <class BaseType>
    class MutableTable : public Table {
    public:
        void insert(const BaseType& t);
    
    };
    
    typedef MutableTable<DataType> GenericTable;
    typedef MutableTable<Number> NumberTable;
    

    Das ist in den vielen Fällen völlig ausreichend, da die Datenquelle meistens sowieso streng-getypt ist, also weiß, welche Art von Daten sie liefert. Wenn meine Datenquelle weiß, dass sie Numbers liest, demzufolge also bereits von Number abhängig ist, dann kann sie auch MutableTable<Number> verwenden ohne, dass dadurch neue Abhängigkeiten entstehen.



  • Danke.
    Ich werd mal eine Weile darueber medieteren.


Anmelden zum Antworten