Як ви кодуєте алгебраїчні типи даних мовою C # - або Java?


58

Існують деякі проблеми, які легко вирішуються алгебраїчними типами даних, наприклад, тип списку може бути дуже коротко виражений як:

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

Цей конкретний приклад є в Haskell, але він би був подібний в інших мовах із вбудованою підтримкою алгебраїчних типів даних.

Виявляється, є очевидне відображення підтипу в стилі OO: тип даних стає абстрактним базовим класом, а кожен конструктор даних стає конкретним підкласом. Ось приклад у 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+)

Єдине, що потрібно поза наївним підкласом - це спосіб закріплення класів, тобто спосіб унеможливити додавання підкласів до ієрархії.

Як би ви підійшли до цієї проблеми такою мовою, як C # або Java? Два камені спотикання, які я виявив, намагаючись використовувати алгебраїчні типи даних у C #, були:

  • Я не міг розібратися, як називається нижній тип у C # (тобто я не міг розібратися, що вкласти class Empty : ConsList< ??? >)
  • Я не міг знайти спосіб закріплення, ConsList щоб ніякі підкласи не могли бути додані до ієрархії

Який був би найбільш ідіоматичний спосіб впровадження алгебраїчних типів даних у C # та / або Java? Або, якщо це неможливо, що було б ідіоматичною заміною?



3
C # - мова OOP. Вирішити проблеми за допомогою OOP. Не намагайтеся використовувати будь-яку іншу парадигму.
Ейфорія

7
@Euphoric C # стала досить зручною функціональною мовою за допомогою C # 3.0. Першокласні функції, вбудовані загальні функціональні операції, монади.
Маурісіо Шеффер

2
@Euphoric: деякі домени легко моделювати з об'єктами і важко моделювати з алгебраїчними типами даних, деякі - навпаки. Знання як зробити це дає вам більшу гнучкість у моделюванні вашого домену. І як я вже говорив, відображення алгебраїчних типів даних до типових понять OO не так вже й складно: тип даних стає абстрактним базовим класом (або інтерфейсом, або абстрактною ознакою), конструктори даних стають конкретними підкласами реалізації. Це дає вам відкритий алгебраїчний тип даних. Обмеження успадкування дає вам закритий алгебраїчний тип даних. Поліморфізм дає дискримінацію у випадку.
Йорг W Міттаг

3
@Euphoric, парадигма, schmaradigm, кого це хвилює? ADT є ортогональними для функціонального програмування (або OOP або іншого). Кодування AST будь-якої мови є досить болючим без пристойної підтримки ADT, а компіляція цієї мови - це біль без іншої парадигмо-агностичної функції, відповідності шаблону.
SK-логіка

Відповіді:


42

Існує простий, але важкий спосіб запечатати класи на Java. Ви ставите приватний конструктор в базовий клас, після чого робите підкласи внутрішніх класів цього.

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;
      }
   }
}

Візьміть за схемою відвідувача для відправки.

Мій проект jADT: Java Algebraic DataTypes генерує для вас усі ці шаблони https://github.com/JamesIry/jADT


2
Я чомусь не дивуюсь, коли твоє ім’я спливає тут! Дякую, я не знав цієї ідіоми.
Йорг W Міттаг

4
Коли ви сказали, що "котельня важка", я був готовий до чогось гіршого ;-) Іноді Java може бути дуже поганою з плитою котла.
Йоахім Зауер

але це не складається: у вас немає способу спеціалізуватися на типі A без необхідності підтверджувати це через акторський склад (я думаю)
nicolas

Це, на жаль, здається нездатним представити деякі складніші типи сум, наприклад Either. Дивіться моє запитання
Zoey Hewll

20

Ви можете досягти цього, використовуючи шаблон відвідувача , який доповнить відповідність шаблону. Наприклад

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

можна записати на Java як

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);
    }
}

Герметизація досягається Visitorкласом. Кожен з його методів оголошує, як деконструювати один із підкласів. Ви можете додати більше підкласів, але це доведеться реалізовувати acceptта викликати один із visit...методів, тож він повинен або поводитись так, Consабо подобається Nil.


13

Якщо ви зловживаєте параметрами з назвою C # (введені в C # 4.0), ви можете створювати алгебраїчні типи даних, які легко узгоджуються:

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);

Ось реалізація Eitherкласу:

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);
    }
}

Я вже бачив версію Java для цієї техніки раніше, але лямбда і названі параметри роблять її настільки зрозумілою. +1!
Doval

1
Я думаю, що тут проблема полягає в тому, що Right не є загальним щодо типу помилок. Щось на зразок:, class Right<R> : Either<Bot,R>де або змінюється інтерфейс з параметрами коваріантного (вихідного) типу, а Bot - нижній тип (підтип будь-якого іншого типу, навпроти Object). Я не думаю, що C # має нижній тип.
крой

5

У C # такого Emptyтипу ви не можете мати , тому що внаслідок модифікації базові типи відрізняються для різних типів членів. Ви можете лише мати Empty<T>; не так корисно.

У Java у вас може виникнути Empty : ConsListчерез стирання типу, але я не впевнений, чи не перевірила б типова перевірка.

Однак, оскільки обидві мови є null, ви можете вважати, що всі їх посилання є "Що б не було". Таким чином, ви просто використовуватимете null"Порожню", щоб не вказувати, що вона отримує.


Проблема nullполягає в тому, що вона занадто загальна: вона представляє відсутність нічого , тобто порожнечі взагалі, але я хочу представляти відсутність елементів списку, тобто порожнього списку зокрема. Порожній список і порожнє дерево повинні мати різні типи. Також порожній список повинен бути фактичним значенням, оскільки він все ще має власну поведінку, тому він повинен мати свої власні методи. Для побудови списку [1, 2, 3]я хочу сказати Empty.prepend(3).prepend(2).prepend(1)(або мовою з операторами, які асоціюються з правою асоціацією 1 :: 2 :: 3 :: Empty), але я не можу сказати null.prepend ….
Йорг W Міттаг

@ JörgWMittag: Нулі мають різні типи. Ви також можете легко створити набрану константу зі значенням null для цієї мети. Але це правда, що ви не можете викликати методи. Ваш підхід до методів так чи інакше не працює без специфічного для елемента "Пустого".
Ян Худек

деякі хитромудрі методи розширення можуть підробити «метод» викликів нулів (звичайно, це все дійсно статично)
jk.

Ви можете мати Emptyі Empty<>та зловживання неявні оператори перетворення , щоб дозволити досить практичне моделювання, якщо ви хочете. По суті, ви використовуєте Emptyв коді, але всі підписи типу тощо використовують лише загальні варіанти.
Еймон Нербонна

3

Єдине, що потрібно поза наївним підкласом - це спосіб закріплення класів, тобто спосіб унеможливити додавання підкласів до ієрархії.

На Java ви не можете. Але ви можете оголосити базовий клас приватним пакетом, що означає, що всі прямі підкласи повинні належати тому ж пакету, що і базовий клас. Якщо потім оголосити підкласи як остаточні, вони більше не можуть бути підкласами.

Я не знаю, чи це вирішить вашу справжню проблему, хоча ...


У мене немає справжньої проблеми, або я б розмістив це на StackOverflow, не тут :-) Важливою властивістю Algebraic Data Types є те, що вони можуть бути закриті , а це означає, що кількість випадків виправлена: у цьому прикладі , список або порожній, або його немає. Якщо я можу статично переконатися, що це так, то я можу зробити динамічні касти або динамічні intanceofперевірки "псевдобезпечними" (тобто: я знаю, що це безпечно, навіть якщо компілятор цього не робить), просто гарантуючи, що я завжди перевірити ці два випадки. Якщо ж хтось додасть новий підклас, я можу отримати помилки виконання, яких я не очікував.
Йорг W Міттаг

@ JörgWMittag - Ну Java явно не підтримує цього ... у тому сильному сенсі, який ви, здається, хочете. Звичайно, ви можете робити різні речі, щоб блокувати небажані підтипи під час виконання, але тоді ви отримаєте "помилки виконання, яких ви не очікуєте".
Стівен C

3

Тип даних ConsList<A>може бути представлений як інтерфейс. Інтерфейс відкриває єдиний deconstructметод, який дозволяє "деконструювати" значення цього типу - тобто обробляти кожен з можливих конструкторів. Виклики deconstructметоду аналогічні case ofформі в Haskell або ML.

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

deconstructМетод приймає функцію «зворотного виклику» для кожного конструктора в ADT. У нашому випадку вона займає функцію для випадку порожнього списку та іншу функцію для випадку "проти комірки".

Кожна функція зворотного виклику приймає в якості аргументів значення, які приймає конструктор. Таким чином, випадок "порожній список" не бере аргументів, але випадок "проти комірки" має два аргументи: голова та хвіст списку.

Ми можемо кодувати ці "кілька аргументів" за допомогою Tupleкласів або використовуючи currying. У цьому прикладі я вирішив використовувати простий Pairклас.

Інтерфейс реалізується один раз для кожного конструктора. По-перше, у нас є реалізація для "порожнього списку". deconstructРеалізація просто викликає emptyCaseфункцію зворотного виклику.

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());
  }
}

Потім аналогічно реалізуємо випадок "проти комірки". Цього разу клас має властивості: голова та хвіст не порожнього списку. У deconstructреалізації ці властивості передаються consCaseфункції зворотного виклику.

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));
  }
}

Ось приклад використання цього кодування ADT: ми можемо записати reduceфункцію, яка є звичайним складанням списків.

<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))
  );
}

Це аналогічно цій реалізації в Haskell:

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

Цікавий підхід, дуже приємно! Я бачу підключення до F # Active Patterns та Scala Extractors (і там, мабуть, є посилання на Haskell Views, про яке я, на жаль, нічого не знаю). Я не думав переносити відповідальність за відповідність шаблонів над конструкторами даних у сам екземпляр ADT.
Йорг W Міттаг

2

Єдине, що потрібно поза наївним підкласом - це спосіб закріплення класів, тобто спосіб унеможливити додавання підкласів до ієрархії.

Як би ви підійшли до цієї проблеми такою мовою, як C # або Java?

Це не дуже хороший спосіб зробити це, але якщо ви готові жити з огидним хаком, ви можете додати деяку явну перевірку типу до конструктора абстрактного базового класу. У Java це було б щось на кшталт

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

У C # це складніше через перероблені дженерики - найпростішим підходом може бути перетворення типу в рядок і mangle.

Зауважте, що в Java навіть цей механізм теоретично можна обійти тим, хто дійсно хоче через модель серіалізації або sun.misc.Unsafe.


1
Це не було б складніше в C #:Type type = this.GetType(); if (type != typeof(Empty<T>) && type != typeof(ConsCell<T>)) throw new Exception();
svick

@svick, добре спостерігається. Я не враховував, що базовий тип буде параметризований.
Пітер Тейлор

Блискуче! Я думаю, що це досить добре для "ручної перевірки статичного типу". Я більше прагну усунути чесні помилки програмування, а не зловмисні наміри.
Йорг W Міттаг
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.