Programmazione orientata agli oggetti in Haskell

Sto cercando di ottenere una comprensione della programmazione di stile orientata agli oggetti in Haskell, sapendo che le cose saranno un po ‘diverse a causa della mancanza di mutevolezza. Ho giocato con le classi di tipi, ma la mia comprensione di esse è limitata a esse come interfacce. Quindi ho codificato un esempio in C ++, che è il diamante standard con una base pura e un’eredità virtuale. Bat eredita Flying e Mammal , e sia Flying che Mammal ereditano Animal .

 #include  class Animal { public: virtual std::string transport() const = 0; virtual std::string type() const = 0; std::string describe() const; }; std::string Animal::describe() const { return "I am a " + this->transport() + " " + this->type(); } class Flying : virtual public Animal { public: virtual std::string transport() const; }; std::string Flying::transport() const { return "Flying"; } class Mammal : virtual public Animal { public: virtual std::string type() const; }; std::string Mammal::type() const { return "Mammal"; } class Bat : public Flying, public Mammal {}; int main() { Bat b; std::cout << b.describe() << std::endl; return 0; } 

Fondamentalmente sono interessato a come tradurre una struttura di questo tipo in Haskell, in pratica questo mi permetterebbe di avere una lista di Animal , come se potessi avere una serie di puntatori (intelligenti) in Animal s in C ++.

Semplicemente non vuoi farlo, non iniziare nemmeno. OO sicuramente ha i suoi meriti, ma “esempi classici” come il tuo C ++ sono quasi sempre strutture pensate per battere il paradigma nel cervello degli studenti universitari, così non inizieranno a lamentarsi di quanto siano stupide le lingue che dovrebbero usare .

L’idea sembra fondamentalmente modellare “oggetti del mondo reale” da oggetti nel tuo linguaggio di programmazione. Quale può essere un buon approccio per problemi di programmazione effettivi, ma ha senso solo se si può in effetti tracciare un’analogia tra il modo in cui si utilizzerà l’object del mondo reale e come vengono gestiti gli oggetti OO all’interno del programma.

Il che è semplicemente ridicolo per questi esempi di animali. Se mai, i metodi dovrebbero essere roba come “alimentazione”, “latte”, “macellazione” … ma “trasporto” è un termine improprio, direi che per spostare effettivamente l’animale, che sarebbe piuttosto un metodo dell’ambiente in cui vive l’animale, e fondamentalmente ha senso solo come parte di un modello di visitatore.

describe , type e ciò che chiamate transport sono, d’altra parte, molto più semplici. Queste sono fondamentalmente costanti dipendenti dal tipo o semplici funzioni pure. Solo la paranoia OO ratifica rendendoli metodi di class.

Qualsiasi cosa sulla falsariga di questa roba animale, in cui ci sono fondamentalmente solo dati , diventa molto più semplice se non provi a forzarla in qualcosa di simile a OO, ma stai semplicemente con (utilmente digitato) i dati in Haskell.

Quindi, dato che ovviamente questo esempio non ci porta più in là, consideriamo qualcosa in cui l’OOP abbia senso. I toolkit Widget vengono in mente. Qualcosa di simile a

 class Widget; class Container : public Widget { std::vector> children; public: // getters ... }; class Paned : public Container { public: Rectangle childBoundaries(int) const; }; class ReEquipable : public Container { public: void pushNewChild(std::unique_ptr&&); void popChild(int); }; class HJuxtaposition: public Paned, public ReEquipable { ... }; 

Perché OO ha senso qui? Innanzitutto, ci consente facilmente di memorizzare una raccolta eterogenea di widget. Questo in realtà non è facile da ottenere in Haskell, ma prima di provarlo, potresti chiederti se ne hai davvero bisogno. Per alcuni contenitori, forse non è così desiderabile permetterlo, dopotutto. In Haskell, il polimorfismo parametrico è molto piacevole da usare. Per qualsiasi tipo di widget, osserviamo che la funzionalità di Container praticamente si riduce a una semplice lista. Quindi perché non usare solo un elenco, ovunque sia necessario un Container ?

Naturalmente, in questo esempio, probabilmente troverai che hai bisogno di contenitori eterogenei; il modo più diretto per ottenerli è {-# LANGUAGE ExistentialQuantification #-} :

 data GenericWidget = GenericWidget { forall w . Widget w => getGenericWidget :: w } 

In questo caso il Widget sarebbe una class di tipo (potrebbe essere una traduzione piuttosto letterale della class astratta Widget ). In Haskell questa è piuttosto una cosa da fare, ma potrebbe essere proprio qui.

Paned è più un’interfaccia. Potremmo usare un’altra class di tipi qui, fondamentalmente la traslitterazione di C ++:

 class Paned c where childBoundaries :: c -> Int -> Maybe Rectangle 

ReEquipable è più difficile, perché i suoi metodi effettivamente mutano il contenitore. Questo è ovviamente problematico in Haskell. Ma ancora una volta potresti scoprire che non è necessario: se hai sostituito la class Container con elenchi semplici, potresti essere in grado di eseguire gli aggiornamenti come aggiornamenti puramente funzionali.

Probabilmente però, questo sarebbe troppo inefficiente per l’attività in corso. Discutere in modo completo i modi per fare aggiornamenti mutabili in modo efficiente sarebbe troppo per lo scopo di questa risposta, ma esistono modi simili, ad esempio l’uso di lenses .

Sommario

OO non si traduce troppo bene in Haskell. Non esiste un semplice isomorfismo generico, solo le approssimazioni multiple tra cui scegliere richiede esperienza. Il più spesso ansible, dovresti evitare di affrontare il problema da un angolo OO e pensare invece a dati, funzioni, livelli di monade. Si scopre che questo ti porta molto lontano in Haskell. Solo in alcune applicazioni, OO è così naturale che vale la pena di inserirlo nella lingua.


Scusa, questo argomento mi spinge sempre in una modalità rant di opinione forte …

Queste paranoie sono in parte motivate dai problemi di mutabilità, che non sorgono in Haskell.

In Haskell non esiste un buon metodo per creare “alberi” di eredità. Invece, di solito facciamo qualcosa di simile

 data Animal = Animal ... data Mammal = Mammal Animal ... data Bat = Bat Mammal ... 

Quindi incapsuliamo informazioni comuni. Che non è così raro in OOP, “favorire la composizione sull’eredità”. Successivamente creiamo queste interfacce, chiamate classi di tipi

 class Named a where name :: a -> String 

Poi faremmo le istanze di Named di Animal , Mammal e Bat che comunque avevano senso per ognuna di esse.

Da quel momento in poi, dovremmo semplicemente scrivere le funzioni nella combinazione appropriata di classi di tipi, non ci interessa davvero che Bat abbia un Animal sepolto al suo interno con un nome. Diciamo solo

 prettyPrint :: Named a => a -> String prettyPrint a = "I love " ++ name a ++ "!" 

e lasciamo che i tipici typeclass si preoccupino di capire come gestire i dati specifici. Questo, diciamo, scriviamo codice più sicuro in molti modi, per esempio

 foo :: Top -> Top bar :: Topped a => a -> a 

Con foo , non abbiamo idea di quale sottotipo di Top venga restituito, dobbiamo fare brutto casting basato su runtime per capirlo. Con la bar , garantiamo staticamente che ci atteniamo alla nostra interfaccia, ma che l’implementazione sottostante è coerente per tutta la funzione. Ciò rende molto più facile comporre in modo sicuro funzioni che funzionano su interfacce diverse per lo stesso tipo.

TLDR; In Haskell, componiamo i dati trattati in modo più compositivo, quindi facciamo affidamento su un polimorfismo parametrico limitato per garantire un’astrazione sicura tra i tipi di calcestruzzo senza sacrificare le informazioni sul tipo.

Ci sono molti modi per implementarlo con successo in Haskell, ma pochi che “sentiranno” molto come Java. Ecco un esempio: modelleremo ciascun tipo in modo indipendente ma forniremo operazioni “cast” che ci consentiranno di trattare i sottotipi di Animal come Animal

 data Animal = Animal String String String data Flying = Flying String String data Mammal = Mammal String String castMA :: Mammal -> Animal castMA (Mammal transport description) = Animal transport "Mammal" description castFA :: Flying -> Animal castFA (Flying type description) = Animal "Flying" type description 

Puoi quindi ovviamente creare una lista di Animal senza problemi. A volte alle persone piace implementarlo tramite ExistentialTypes e typeclasss

 class IsAnimal a where transport :: a -> String type :: a -> String description :: a -> String instance IsAnimal Animal where transport (Animal tr _ _) = tr type (Animal _ t _) = t description (Animal _ _ d) = d instance IsAnimal Flying where ... instance IsAnimal Mammal where ... data AnyAnimal = forall t. IsAnimal t => AnyAnimal t 

che ci consente di iniettare Flying e Mammal direttamente in una lista insieme

 animals :: [AnyAnimal] animals = [AnyAnimal flyingType, AnyAnimal mammalType] 

ma questo in realtà non è molto meglio dell’esempio originale dato che abbiamo gettato via tutte le informazioni su ogni elemento dell’elenco eccetto che ha un’istanza IsAnimal , che, guardando attentamente, è del tutto equivalente a dire che è solo un Animal .

 projectAnimal :: IsAnimal a => a -> Animal projectAnimal a = Animal (transport a) (type a) (description a) 

Quindi potremmo anche aver seguito la prima soluzione.

Molte altre risposte suggeriscono già come le classi di tipi potrebbero essere interessanti per te. Tuttavia, voglio sottolineare che, nella mia esperienza, molte volte quando pensi che una class di tipizzazione sia la soluzione a un problema, in realtà non lo è. Credo che ciò sia particolarmente vero per le persone con un background OOP.

In realtà c’è un articolo del blog molto popolare su questo, Haskell Antipattern: Existential Typeclass , potresti divertirti!

Un approccio più semplice al tuo problema potrebbe essere quello di modellare l’interfaccia come un semplice tipo di dati algebrico, ad es

 data Animal = Animal { animalTransport :: String, animalType :: String } 

In modo tale che il tuo bat diventi un semplice valore:

 flyingTransport :: String flyingTransport = "Flying" mammalType :: String mammalType = "Mammal" bat :: Animal bat = Animal flyingTransport mammalType 

Con questo a portata di mano, puoi definire un programma che descriva qualsiasi animale, proprio come fa il tuo programma:

 describe :: Animal -> String describe a = "I am a " ++ animalTransport a ++ " " ++ animalType a main :: IO () main = putStrLn (describe bat) 

In questo modo è facile avere una lista di valori Animal e ad esempio stampare la descrizione di ciascun animale.