Come si codificano i tipi di dati algebrici in un linguaggio simile a C # o Java?


58

Esistono alcuni problemi che possono essere facilmente risolti dai tipi di dati algebrici, ad esempio un tipo di elenco può essere espresso in modo molto sintetico come:

data ConsList a = Empty | ConsCell a (ConsList a)

consmap f Empty          = Empty
consmap f (ConsCell a b) = ConsCell (f a) (consmap f b)

l = ConsCell 1 (ConsCell 2 (ConsCell 3 Empty))
consmap (+1) l

Questo esempio particolare è in Haskell, ma sarebbe simile in altre lingue con il supporto nativo per i tipi di dati algebrici.

Si scopre che esiste una mappatura ovvia al sottotitolo in stile OO: il tipo di dati diventa una classe base astratta e ogni costruttore di dati diventa una sottoclasse concreta. Ecco un esempio in Scala:

sealed abstract class ConsList[+T] {
  def map[U](f: T => U): ConsList[U]
}

object Empty extends ConsList[Nothing] {
  override def map[U](f: Nothing => U) = this
}

final class ConsCell[T](first: T, rest: ConsList[T]) extends ConsList[T] {
  override def map[U](f: T => U) = new ConsCell(f(first), rest.map(f))
}

val l = (new ConsCell(1, new ConsCell(2, new ConsCell(3, Empty)))
l.map(1+)

L'unica cosa necessaria oltre l'ingenua sottoclasse è un modo per sigillare le classi, cioè un modo per rendere impossibile aggiungere sottoclassi a una gerarchia.

Come affronteresti questo problema in un linguaggio come C # o Java? I due blocchi che ho trovato durante il tentativo di utilizzare i tipi di dati algebrici in C # erano:

  • Non sono riuscito a capire come si chiama il tipo di fondo in C # (cioè non sono riuscito a capire in cosa inserire class Empty : ConsList< ??? >)
  • Non sono riuscito a trovare un modo per sigillare in ConsList modo che nessuna sottoclasse possa essere aggiunta alla gerarchia

Quale sarebbe il modo più idiomatico per implementare i tipi di dati algebrici in C # e / o Java? Oppure, se non fosse possibile, quale sarebbe la sostituzione idiomatica?



3
C # è il linguaggio OOP. Risolvi i problemi usando OOP. Non provare a usare nessun altro paradigma.
Euforico

7
@Euphoric C # è diventato un linguaggio funzionale abbastanza utilizzabile con C # 3.0. Funzioni di prima classe, operazioni funzionali comuni integrate, monadi.
Mauricio Scheffer,

2
@Euforico: alcuni domini sono facili da modellare con oggetti e difficili da modellare con tipi di dati algebrici, altri sono l'opposto. Sapere come fare entrambi ti offre maggiore flessibilità nella modellazione del tuo dominio. E come ho detto, mappare i tipi di dati algebrici su concetti tipici di OO non è così complesso: il tipo di dati diventa una classe base astratta (o un'interfaccia o un tratto astratto), i costruttori di dati diventano sottoclassi di implementazione concrete. Questo ti dà un tipo di dati algebrico aperto. Le restrizioni sull'eredità offrono un tipo di dati algebrico chiuso. Il polimorfismo ti fa discriminare il caso.
Jörg W Mittag,

3
@Euforico, paradigma, schmaradigm, a chi importa? Gli ADT sono ortogonali alla programmazione funzionale (o OOP o quant'altro). Codificare un AST di qualsiasi lingua è piuttosto doloroso senza il supporto di ADT decenti, e compilare quel linguaggio è un dolore senza un'altra caratteristica paradigmatica, il pattern matching.
SK-logic

Risposte:


42

Esiste un modo semplice ma pesante per sigillare le classi in Java. Metti un costruttore privato nella classe base e poi ne fai delle sottoclassi nelle classi interne.

public abstract class List<A> {

   // private constructor is uncallable by any sublclasses except inner classes
   private List() {
   }

   public static final class Nil<A> extends List<A> {
   }

   public static final class Cons<A> extends List<A> {
      public final A head;
      public final List<A> tail;

      public Cons(A head, List<A> tail) {
         this.head = head;
         this.tail = tail;
      }
   }
}

Attenersi a un modello di visitatore per la spedizione.

Il mio progetto jADT: Java Algebraic DataTypes genera tutta quella caldaia per te https://github.com/JamesIry/jADT


2
In qualche modo non sono sorpreso di vedere apparire il tuo nome qui! Grazie, non conoscevo questo idioma.
Jörg W Mittag,

4
Quando hai detto "boilerplate heavy" ero preparato a qualcosa di molto peggio ;-) Java può essere piuttosto male con il boilerplate, a volte.
Joachim Sauer,

ma questo non compone: non hai modo di specializzare il tipo A senza doverlo affermare attraverso un cast (credo)
nicolas,

Questo purtroppo sembra incapace di rappresentare alcuni tipi di somma più complessi, ad es Either. Vedi la mia domanda
Zoey Hewll

20

È possibile ottenere ciò utilizzando il modello visitatore , che integrerà la corrispondenza dei modelli. Per esempio

data List a = Nil | Cons { value :: a, sublist :: List a }

può essere scritto in Java come

interface List<T> {
    public <R> R accept(Visitor<T,R> visitor);

    public static interface Visitor<T,R> {
        public R visitNil();
        public R visitCons(T value, List<T> sublist);
    }
}

final class Nil<T> implements List<T> {
    public Nil() { }

    public <R> R accept(Visitor<T,R> visitor) {
        return visitor.visitNil();
    }
}
final class Cons<T> implements List<T> {
    public final T value;
    public final List<T> sublist;

    public Cons(T value, List<T> sublist) {
        this.value = value;
        this.sublist = sublist;
    }

    public <R> R accept(Visitor<T,R> visitor) {
        return visitor.visitCons(value, sublist);
    }
}

La tenuta viene raggiunta dalla Visitorclasse. Ciascuno dei suoi metodi dichiara come decostruire una delle sottoclassi. Potresti aggiungere più sottoclassi, ma dovrebbe implementare accepte chiamando uno dei visit...metodi, quindi dovrebbe comportarsi come Conso come Nil.


13

Se si abusano dei parametri con nome C # (introdotti in C # 4.0), è possibile creare tipi di dati algebrici su cui è facile corrispondere:

Either<string, string> e = MonthName(2);

// Match with no return value.
e.Match
(
    Left: err => { Console.WriteLine("Could not convert month: {0}", err); },
    Right: name => { Console.WriteLine("The month is {0}", name); }
);

// Match with a return value.
string monthName =
    e.Match
    (
        Left: err => null,
        Right: name => name
    );
Console.WriteLine("monthName: {0}", monthName);

Ecco l'implementazione della Eitherclasse:

public abstract class Either<L, R>
{
    // Subclass implementation calls the appropriate continuation.
    public abstract T Match<T>(Func<L, T> Left, Func<R, T> Right);

    // Convenience wrapper for when the caller doesn't want to return a value
    // from the match expression.
    public void Match(Action<L> Left, Action<R> Right)
    {
        this.Match<int>(
            Left: x => { Left(x); return 0; },
            Right: x => { Right(x); return 0; }
        );
    }
}

public class Left<L, R> : Either<L, R>
{
    L Value {get; set;}

    public Left(L Value)
    {
        this.Value = Value;
    }

    public override T Match<T>(Func<L, T> Left, Func<R, T> Right)
    {
        return Left(Value);
    }
}

public class Right<L, R> : Either<L, R>
{
    R Value { get; set; }

    public Right(R Value)
    {
        this.Value = Value;
    }

    public override T Match<T>(Func<L, T> Left, Func<R, T> Right)
    {
        return Right(Value);
    }
}

Ho già visto una versione Java di questa tecnica, ma lambdas e parametri nominati la rendono molto leggibile. +1!
Doval,

1
Penso che il problema qui sia che Right non è generico rispetto al tipo di errore. Qualcosa di simile class Right<R> : Either<Bot,R>:, in cui O è cambiato in un'interfaccia con parametri di tipo covariante (out), e Bot è il tipo in basso (sottotipo di ogni altro tipo, opposto a Object). Non credo che C # abbia un tipo di fondo.
croyd

5

In C #, non puoi avere quel Emptytipo, perché, a causa della reificazione, i tipi di base sono diversi per i diversi tipi di membri. Puoi solo avere Empty<T>; non molto utile.

In Java, potresti aver Empty : ConsListdovuto cancellare il tipo, ma non sono sicuro che il controllo del tipo non avrebbe urlato da qualche parte.

Tuttavia, poiché entrambe le lingue hanno null, puoi pensare a tutti i loro tipi di riferimento come "Qualunque | Null". Quindi useresti semplicemente nullcome "Vuoto" per evitare di dover specificare da cosa deriva.


Il problema nullè che è troppo generale: rappresenta l'assenza di qualcosa , cioè il vuoto in generale, ma voglio rappresentare l'assenza di elementi di elenco, ovvero un elenco vuoto in particolare. Un elenco vuoto e un albero vuoto dovrebbero avere tipi distinti. Inoltre, l'elenco vuoto deve essere un valore reale perché ha ancora un comportamento proprio, quindi deve avere i suoi metodi. Per costruire la lista [1, 2, 3], voglio dire Empty.prepend(3).prepend(2).prepend(1)(o in una lingua con operatori associativi di destra 1 :: 2 :: 3 :: Empty), ma non posso dirlo null.prepend ….
Jörg W Mittag,

@ JörgWMittag: i null hanno tipi distinti. È inoltre possibile creare facilmente una costante digitata con valore null allo scopo. Ma è vero che non puoi chiamare metodi su di esso. Il tuo approccio con i metodi non funziona comunque senza Vuoto specifico per tipo di elemento.
Jan Hudec,

alcuni metodi di estensione astuti possono falsare chiamate "method" su null (ovviamente è tutto davvero statico)
jk.

Puoi avere un Emptye un Empty<>e abusare di operatori di conversione implicita per consentire una simulazione abbastanza pratica, se lo desideri. In sostanza, si utilizza Emptynel codice, ma tutte le firme di tipo ecc. Utilizzano solo le varianti generiche.
Eamon Nerbonne,

3

L'unica cosa necessaria oltre l'ingenua sottoclasse è un modo per sigillare le classi, cioè un modo per rendere impossibile aggiungere sottoclassi a una gerarchia.

In Java non puoi. Ma puoi dichiarare la classe base come pacchetto privato, il che significa che tutte le sottoclassi dirette devono appartenere allo stesso pacchetto della classe base. Se poi dichiari le sottoclassi come finali, non possono essere ulteriormente sottoclassate.

Non so se questo risolverà il tuo vero problema però ...


Non ho un vero problema, o l'avrei pubblicato su StackOverflow, non qui :-) Una proprietà importante dei tipi di dati algebrici è che possono essere chiusi , il che significa che il numero di casi è stato risolto: in questo esempio , un elenco è vuoto o non lo è. Se posso garantire staticamente che questo è il caso, allora posso rendere i cast dinamici o i intanceofcontrolli dinamici "pseudo-tipo-sicuri" (cioè: so che è sicuro, anche se il compilatore non lo fa), semplicemente assicurandomi che io sempre controlla quei due casi. Se, tuttavia, qualcun altro aggiunge una nuova sottoclasse, allora posso ottenere errori di runtime che non mi aspettavo.
Jörg W Mittag,

@JörgWMittag - Beh, Java chiaramente non lo supporta ... nel senso forte che sembri volere. Ovviamente, puoi fare varie cose per bloccare il sottotipo indesiderato in fase di runtime, ma poi ottieni "errori di runtime che non ti aspetti".
Stephen C

3

Il tipo di dati ConsList<A>può essere rappresentato come interfaccia. L'interfaccia espone un singolo deconstructmetodo che consente di "decostruire" un valore di quel tipo, ovvero di gestire ciascuno dei possibili costruttori. Le chiamate a un deconstructmetodo sono analoghe a un case ofmodulo in Haskell o ML.

interface ConsList<A> {
  <R> R deconstruct(
    Function<Unit, R> emptyCase,
    Function<Pair<A,ConsList<A>>, R> consCase
  );
}

Il deconstructmetodo accetta una funzione di "callback" per ciascun costruttore nell'ADT. Nel nostro caso, prende una funzione per il caso dell'elenco vuoto e un'altra funzione per il caso "contro cella".

Ogni funzione di callback accetta come argomenti i valori accettati dal costruttore. Quindi il caso "lista vuota" non accetta argomenti, ma il caso "cella di contro" accetta due argomenti: la testa e la coda della lista.

Possiamo codificare questi "argomenti multipli" usando le Tupleclassi o usando il curry. In questo esempio, ho scelto di utilizzare una Pairclasse semplice .

L'interfaccia è implementata una volta per ciascun costruttore. Innanzitutto, abbiamo l'implementazione per la "lista vuota". L' deconstructimplementazione chiama semplicemente la emptyCasefunzione di callback.

class ConsListEmpty<A> implements ConsList<A> {
  public ConsListEmpty() {}

  public <R> R deconstruct(
    Function<Unit, R> emptyCase,
    Function<Pair<A,ConsList<A>>, R> consCase
  ) {
    return emptyCase.apply(new Unit());
  }
}

Quindi implementiamo il caso "contro cella" in modo simile. Questa volta la classe ha proprietà: la testa e la coda dell'elenco non vuoto. Nell'implementazione deconstruct, tali proprietà vengono passate alla consCasefunzione di callback.

class ConsListConsCell<A> implements ConsList<A> {
  private A head;
  private ConsList<A> tail;

  public ConsListCons(A head, ConsList<A> tail) {
    this.head = head;
    this.tail = tail;
  }

  public <R> R deconstruct(
    Function<Unit, R> emptyCase,
    Function<Pair<A,ConsList<A>>, R> consCase
  ) {
    return consCase.apply(new Pair<A,ConsList<A>>(this.head, this.tail));
  }
}

Ecco un esempio dell'uso di questa codifica degli ADT: possiamo scrivere una reducefunzione che è la solita lista pieghevole.

<T> T reduce(Function<Pair<T,A>,T> reducer, T initial, ConsList<T> l) {
  return l.deconstruct(
    ((unit) -> initial),
    ((t) -> reduce(reducer, reducer.apply(initial, t.v1), t.v2))
  );
}

Questo è analogo a questa implementazione in Haskell:

reduce reducer initial l = case l of
  Empty -> initial
  Cons t_v1 t_v2  -> reduce reducer (reducer initial t_v1) t_v2

Approccio interessante, molto bello! Riesco a vedere la connessione a F # Active Patterns e Scala Extractor (e probabilmente c'è anche un link lì a Haskell Views, di cui non so nulla, purtroppo). Non avevo pensato di spostare la responsabilità della corrispondenza dei modelli sui costruttori di dati nell'istanza ADT stessa.
Jörg W Mittag,

2

L'unica cosa necessaria oltre l'ingenua sottoclasse è un modo per sigillare le classi, cioè un modo per rendere impossibile aggiungere sottoclassi a una gerarchia.

Come affronteresti questo problema in un linguaggio come C # o Java?

Non c'è un buon modo per farlo, ma se sei disposto a convivere con un trucco orribile, puoi aggiungere un controllo esplicito del tipo al costruttore della classe base astratta. In Java, questo sarebbe qualcosa di simile

protected ConsList() {
    Class<?> clazz = getClass();
    if (clazz != Empty.class && clazz != ConsCell.class) throw new Exception();
}

In C # è più complicato a causa dei generici reificati: l'approccio più semplice potrebbe essere quello di convertire il tipo in una stringa e manipolarlo.

Si noti che in Java anche questo meccanismo può teoricamente essere bypassato da qualcuno che vuole davvero tramite il modello di serializzazione o sun.misc.Unsafe.


1
Non sarebbe più complicato in C #:Type type = this.GetType(); if (type != typeof(Empty<T>) && type != typeof(ConsCell<T>)) throw new Exception();
svick

@svick, ben osservato. Non stavo tenendo conto del fatto che il tipo di base sarebbe stato parametrizzato.
Peter Taylor,

Brillante! Immagino che questo sia abbastanza buono per fare "controllo statico manuale del tipo". Sto più cercando di eliminare errori di programmazione onesti piuttosto che intenzioni dannose.
Jörg W Mittag,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.