Как языки с типами Maybe вместо NULL обрабатывают краевые условия?


53

Эрик Липперт высказал очень интересную мысль в своем обсуждении того, почему C # использует тип, nullа не Maybe<T>тип :

Согласованность системы типов важна; можем ли мы всегда знать, что необнуляемая ссылка ни при каких обстоятельствах не считается недействительной? Как насчет конструктора объекта с ненулевым полем ссылочного типа? Как насчет финализатора такого объекта, где объект завершен, потому что код, который должен был заполнить ссылку, вызвал исключение? Система типов, которая лжет вам о своих гарантиях, опасна.

Это было немного откровением. Эти концепции меня интересуют, и я немного поигрался с компиляторами и системами типов, но я никогда не думал об этом сценарии. Как языки, которые имеют тип Maybe вместо нулевого, обрабатывают такие крайние случаи, как инициализация и восстановление после ошибок, в которых предположительно гарантированная ненулевая ссылка фактически не находится в допустимом состоянии?


Я предполагаю, что если Maybe является частью языка, возможно, он внутренне реализован через нулевой указатель и это просто синтаксический сахар. Но я не думаю, что какой-либо язык на самом деле делает это так.
Panzi

1
@panzi: Цейлон использует чувствительную к потоку типизацию, чтобы различать Type?(возможно) и Type(не ноль)
Лукас Эдер

1
@RobertHarvey В Stack Exchange уже нет кнопки "хороший вопрос"?
user253751

2
@panzi Это хорошая и правильная оптимизация, но она не помогает в решении этой проблемы: если что-то не является Maybe T, это не должно быть, Noneи, следовательно, вы не можете инициализировать его хранилище с нулевым указателем.

@immibis: я уже выдвинул это. Здесь мы получаем очень мало хороших вопросов; Я думал, что этот заслуживает комментария.
Роберт Харви

Ответы:


45

Эта цитата указывает на проблему, которая возникает, если объявление и назначение идентификаторов (здесь: элементы экземпляра) отделены друг от друга. Как быстрый набросок псевдокода:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

Теперь сценарий заключается в том, что во время создания экземпляра будет выдана ошибка, поэтому построение будет прервано до полного создания экземпляра. Этот язык предлагает метод деструктора, который будет запущен до освобождения памяти, например, для ручного освобождения ресурсов, не связанных с памятью. Его также следует запускать на частично построенных объектах, поскольку ресурсы, управляемые вручную, могли быть уже выделены до прекращения построения.

С пустыми значениями деструктор может проверить, была ли переменная назначена как if (foo != null) foo.cleanup(). Без нулей объект теперь находится в неопределенном состоянии - каково значение bar?

Однако эта проблема существует из-за сочетания трех аспектов:

  • Отсутствие значений по умолчанию, таких как nullили гарантированная инициализация для переменных-членов.
  • Разница между декларацией и присвоением. Принудительное присвоение переменных (например, с помощью letоператора, как видно из функциональных языков) - это легко было заставить принудительную инициализацию - но ограничивает язык другими способами.
  • Специфика деструкторов как метода, который вызывается во время выполнения языка.

Легко выбрать другой дизайн, который не демонстрирует этих проблем, например, всегда объединяя объявление с присваиванием и предлагая языку несколько блоков финализатора вместо одного метода финализации:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

Таким образом, проблема заключается не в отсутствии нуля, а в сочетании набора других функций с отсутствием нуля.

Теперь интересный вопрос, почему C # выбрал один дизайн, а не другой. Здесь в контексте цитаты перечислено много других аргументов для нуля в языке C #, которые в основном можно обобщить как «знакомство и совместимость» - и это веские причины.


Есть и еще одна причина, по которой финализатору приходится иметь дело с nulls: порядок финализации не гарантируется из-за возможности ссылочных циклов. Но я думаю, что ваш FINALIZEдизайн также решает, что: если fooон уже завершен, его FINALIZEраздел просто не будет работать.
svick

14

Таким же образом вы гарантируете, что любые другие данные находятся в действительном состоянии.

Можно структурировать семантику и поток управления таким образом, что вы не можете иметь переменную / поле какого-либо типа, не создавая полностью значение для него. Вместо того, чтобы создавать объект и позволять конструктору присваивать «начальные» значения его полям, вы можете только создать объект, указав значения для всех его полей одновременно. Вместо того, чтобы объявлять переменную и затем присваивать начальное значение, вы можете только ввести переменную с инициализацией.

Например, в Rust вы создаете объект типа структуры через Point { x: 1, y: 2 }вместо того, чтобы писать конструктор, который это делает self.x = 1; self.y = 2;. Конечно, это может противоречить стилю языка, который вы имеете в виду.

Другой дополнительный подход - использование анализа живучести для предотвращения доступа к хранилищу до его инициализации. Это позволяет объявлять переменную без немедленной ее инициализации, если она назначается перед первым чтением. Это может также поймать некоторые связанные с отказом случаи как

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

Технически, вы также можете определить произвольную инициализацию по умолчанию для объектов, например, обнулить все числовые поля, создать пустые массивы для полей массива и т. Д., Но это довольно произвольно, менее эффективно, чем другие параметры, и может маскировать ошибки.


7

Вот как это делает Haskell: (не совсем противоречит заявлениям Липперта, поскольку Haskell не является объектно-ориентированным языком).

ПРЕДУПРЕЖДЕНИЕ: длинный обдуманный ответ от серьезного фаната Haskell впереди.

TL; DR

Этот пример точно показывает, насколько Haskell отличается от C #. Вместо того, чтобы делегировать логистику построения конструкции конструктору, она должна обрабатываться в окружающем коде. Нулевое значение (или Nothingв Haskell) не может появиться там, где мы ожидаем ненулевое значение, потому что нулевые значения могут встречаться только в специальных вызываемых типах-обертках, Maybeкоторые не взаимозаменяемы с / напрямую преобразуются в обычные, не обнуляемые типы. Чтобы использовать значение, которое можно обнулять, заключив его в a Maybe, мы должны сначала извлечь значение, используя сопоставление с образцом, что заставляет нас перенаправить поток управления в ветвь, где мы точно знаем, что у нас есть ненулевое значение.

Следовательно:

можем ли мы всегда знать, что необнуляемая ссылка ни при каких обстоятельствах не считается недействительной?

Да. Intи Maybe Intдва совершенно разных типа. Нахождение Nothingна равнине Intбыло бы сравнимо с нахождением строки «рыба» в Int32.

Как насчет конструктора объекта с ненулевым полем ссылочного типа?

Не проблема: конструкторы значений в Haskell ничего не могут сделать, кроме как взять значения, которые они дали, и сложить их вместе. Вся логика инициализации происходит до вызова конструктора.

Как насчет финализатора такого объекта, где объект завершен, потому что код, который должен был заполнить ссылку, вызвал исключение?

В Хаскеле нет финализаторов, поэтому я не могу решить эту проблему. Мой первый ответ все еще остается, однако.

Полный ответ :

Haskell не имеет значения NULL и использует Maybeтип данных для представления значений NULL. Может быть, это тип данных algabraic, определенный следующим образом:

data Maybe a = Just a | Nothing

Для тех из вас знакомы с Haskell, читать это как « Maybeэто либо Nothingили Just a». В частности:

  • Maybeявляется конструктором типа : его можно (неправильно) рассматривать как универсальный класс (где a- переменная типа). Аналогия с C # есть class Maybe<a>{}.
  • Justявляется конструктором значения : это функция, которая принимает один аргумент типа aи возвращает значение типа, Maybe aкоторое содержит значение. Таким образом, код x = Just 17аналогичен int? x = 17;.
  • Nothingэто другой конструктор значений, но он не принимает аргументов, а Maybeвозвращаемое не имеет значения, кроме «Nothing». x = Nothingявляется аналогом int? x = null;(предполагая, что мы ограничены aв Haskell Int, что можно сделать, написав x = Nothing :: Maybe Int).

Теперь, когда основы этого Maybeтипа находятся вне пути, как Haskell избегает вопросов, обсуждаемых в вопросе OP?

Что ж, Haskell действительно отличается от большинства языков, которые обсуждались до сих пор, поэтому я начну с объяснения нескольких основных принципов языка.

Во-первых, в Хаскеле все неизменно . Все. Имена относятся к значениям, а не к областям памяти, в которых можно хранить значения (это само по себе является огромным источником устранения ошибок). В отличие от C #, где объявление переменной и присваивание две отдельные операции, в Haskell значения создаются путем определения их стоимости (например x = 15, y = "quux", z = Nothing), который никогда не может изменить. Следовательно, код вроде:

ReferenceType x;

В Хаскеле это невозможно. Нет проблем с инициализацией значений, nullпотому что все должно быть явно инициализировано значением, чтобы оно существовало.

Во-вторых, Haskell не является объектно-ориентированным языком : это чисто функциональный язык, поэтому в строгом смысле этого слова нет объектов. Вместо этого есть просто функции (конструкторы значений), которые принимают свои аргументы и возвращают объединенную структуру.

Далее нет абсолютно никакого императивного кода стиля. Под этим я подразумеваю, что большинство языков следуют шаблону примерно так:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

Поведение программы выражается в виде серии инструкций. В объектно-ориентированных языках объявления классов и функций также играют огромную роль в потоке программы, но, по сути, «мясо» выполнения программы принимает форму последовательности инструкций, которые должны быть выполнены.

В Хаскеле это невозможно. Вместо этого выполнение программы полностью определяется цепочечными функциями. Даже императивно-выглядящая doнотация является просто синтаксическим сахаром для передачи анонимных функций >>=оператору. Все функции имеют вид:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Где body-expressionможет быть что-либо, что оценивается в значение. Очевидно, что доступно больше синтаксических функций, но главное - полное отсутствие последовательностей операторов.

Наконец, и, возможно, самое главное, система типов Хаскелла невероятно строгая. Если бы мне пришлось суммировать основную философию проектирования системы типов Haskell, я бы сказал: «Сделайте так, чтобы как можно больше вещей не работало во время компиляции, чтобы как можно меньше работало во время выполнения». Не существует никаких неявных преобразований (хотите продвигать Intк a Double? Используйте fromIntegralфункцию). Единственное, что может иметь недопустимое значение во время выполнения, - это использовать Prelude.undefined(который, очевидно, просто должен быть там и его невозможно удалить ).

Имея все это в виду, давайте посмотрим на «сломанный» пример amon и попытаемся повторно выразить этот код в Haskell. Во-первых, объявление данных (с использованием синтаксиса записи для именованных полей):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( fooи barдействительно являются функциями доступа к анонимным полям здесь, а не к фактическим полям, но мы можем игнорировать эту деталь).

Конструктор NotSoBrokenзначения не способен предпринимать какие-либо действия, кроме взятия a Fooи a Bar(которые не могут быть обнуляемыми), и создавать NotSoBrokenих из них. Там нет места, чтобы поставить императивный код или даже вручную назначить поля. Вся логика инициализации должна происходить в другом месте, скорее всего, в специальной фабричной функции.

В приведенном примере конструкция Brokenвсегда терпит неудачу. Нет никакого способа сломать NotSoBrokenконструктор значений подобным образом (просто некуда писать код), но мы можем создать фабричную функцию, которая будет аналогично дефектной.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(первая строка - объявление сигнатуры типа: makeNotSoBrokenпринимает аргументы a Fooи a Barи выдает a Maybe NotSoBroken).

Тип возвращаемого значения должен быть, Maybe NotSoBrokenа не просто NotSoBrokenпотому, что мы указали его для оценки Nothing, который является конструктором значения Maybe. Типы просто не будут выстраиваться, если мы напишем что-то другое.

Помимо того, что эта функция абсолютно бессмысленна, эта функция даже не выполняет своего реального предназначения, как мы увидим, когда попробуем ее использовать. Давайте создадим функцию с именем, useNotSoBrokenкоторая ожидает NotSoBrokenв качестве аргумента:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenпринимает NotSoBrokenв качестве аргумента a и выдает a Whatever).

И используйте это так:

useNotSoBroken (makeNotSoBroken)

В большинстве языков такое поведение может вызвать исключение нулевого указателя. В Haskell типы не совпадают: makeNotSoBrokenвозвращает a Maybe NotSoBroken, но useNotSoBrokenожидает a NotSoBroken. Эти типы не являются взаимозаменяемыми, и код не компилируется.

Чтобы обойти это, мы можем использовать caseоператор для ветвления на основе структуры Maybeзначения (используя функцию, называемую сопоставлением с шаблоном ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

Очевидно, этот фрагмент должен быть помещен в некоторый контекст для фактической компиляции, но он демонстрирует основы того, как Haskell обрабатывает обнуляемые значения. Вот пошаговое объяснение приведенного выше кода:

  • Сначала makeNotSoBrokenоценивается, что гарантированно выдает значение типа Maybe NotSoBroken.
  • caseЗаявление проверяет структуру этого значения.
  • Если значение равно Nothing, код «обрабатывать ситуацию здесь» оценивается.
  • Если вместо этого значение совпадает со Justзначением, выполняется другая ветвь. Обратите внимание, что соответствующее предложение одновременно идентифицирует значение как Justконструкцию и привязывает его внутреннее NotSoBrokenполе к имени (в данном случае, x). xзатем можно использовать как нормальное NotSoBrokenзначение, которое есть.

Таким образом, сопоставление с образцом обеспечивает мощное средство для обеспечения безопасности типов, поскольку структура объекта неразрывно связана с ветвлением управления.

Я надеюсь, что это было понятное объяснение. Если это не имеет смысла, прыгайте в Learn You A Haskell For Great Good! , один из лучших онлайн языковых уроков, которые я когда-либо читал. Надеюсь, вы увидите ту же красоту на этом языке, что и я.


TL; DR должен быть на вершине :)
andrew.fox

@ andrew.fox Хороший вопрос. Я отредактирую
Приближается к

0

Я думаю, что ваша цитата - аргумент соломенного чучела.

Современные языки сегодня (включая C #) гарантируют, что конструктор либо полностью завершен, либо нет.

Если в конструкторе есть исключение, и объект оставлен частично неинициализированным, то наличие nullили Maybe::noneнеинициализированное состояние не имеет реальной разницы в коде деструктора.

Вам просто придется иметь дело с этим в любом случае. Когда есть внешние ресурсы для управления, вы должны управлять ими явно любым способом. Языки и библиотеки могут помочь, но вы должны подумать об этом.

Кстати: в C # nullзначение в значительной степени эквивалентно Maybe::none. Вы можете назначить nullтолько переменным и членам объекта, которые на уровне типа объявлены как обнуляемые :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Это ничем не отличается от следующего фрагмента:

Maybe<String> optionalString = getOptionalString();

Итак, в заключение, я не вижу, насколько обнуляемость каким-либо образом противоположна Maybeтипам. Я бы даже предположил, что C # пробрался в свой собственный Maybeтип и назвал его Nullable<T>.

С помощью методов расширения легко очистить Nullable, следуя монадическому шаблону:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
что значит "конструктор либо полностью завершает, либо нет"? Например, в Java инициализация (не финального) поля в конструкторе не защищена от гонки данных - это квалифицируется как полное заполнение или нет?
комнат

@gnat: что вы подразумеваете под «Например, в Java инициализация (не финального) поля в конструкторе не защищена от гонки данных». Если вы не сделаете что-то поразительно сложное с участием нескольких потоков, шансы состязания внутри конструктора (или должны быть) почти невозможны. Вы не можете получить доступ к полю неструктурированного объекта, кроме как из конструктора объекта. И если строительство не удается, у вас нет ссылки на объект.
Роланд Тепп

Большая разница между nullнеявным членом каждого типа и тем Maybe<T>, что будет с Maybe<T>, вы также можете иметь просто T, который не имеет никакого значения по умолчанию.
svick

При создании массивов зачастую невозможно определить полезные значения для всех элементов без необходимости их чтения, а также не будет возможности статически проверить, что ни один элемент не считан, если для него не было вычислено полезное значение. Лучшее, что можно сделать - это инициализировать элементы массива таким образом, чтобы их можно было распознать как непригодные для использования.
суперкат

@svick: В C # (который был языком, о котором говорил OP), nullне является неявным членом каждого типа. Для того, nullчтобы быть лебальным значением, вам нужно определить тип, который должен быть обнуляемым явно, что делает T?(синтаксический сахар для Nullable<T>) по существу эквивалентным Maybe<T>.
Роланд Тепп

-3

C ++ делает это, имея доступ к инициализатору, который находится перед телом конструктора. C # запускает инициализатор по умолчанию перед телом конструктора, он приблизительно присваивает 0 всем, floatsстановится 0.0, boolsстановится ложным, ссылки становятся нулевыми и т. Д. В C ++ вы можете заставить его запускать другой инициализатор, чтобы гарантировать, что ненулевой ссылочный тип никогда не будет нулевым ,

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
вопрос был о языках с типами Maybe
gnat

3
« Ссылки становятся нулевыми » - вся предпосылка вопроса заключается в том, что у нас его нет null, и единственный способ указать отсутствие значения - это использовать Maybeтип (также известный как Option), которого AFAIK C ++ не имеет в стандартная библиотека. Отсутствие нулей позволяет нам гарантировать, что поле всегда будет действительным как свойство системы типов . Это более надежная гарантия, чем ручная проверка на отсутствие пути к коду, где переменная все еще может быть null.
Амон

Хотя c ++ изначально не имеет типов Maybe явно, такие вещи, как std :: shared_ptr <T> достаточно близки, так что я думаю, что все еще актуально, что c ++ обрабатывает случай, когда инициализация переменных может происходить «вне области видимости» конструктора, и фактически требуется для ссылочных типов (&), поскольку они не могут быть нулевыми.
FryGuy
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.