Лучшее объяснение для языков без нулевого
Каждый раз, когда программисты жалуются на нулевые ошибки/исключения, кто-то спрашивает, что мы делаем без нуля.
У меня есть основная идея прохлады типов опций, но у меня нет навыков знания или языков, которые лучше всего выражают. Что такое великое объяснение следующего, написанного способом, доступным для среднего программиста, который мы могли бы указать на этого человека?
- Нежелательность наличия ссылок/указателей по умолчанию не допускается.
- Как работают типы параметров, включая стратегии, облегчающие проверку нулевых случаев, таких как
- соответствие шаблону и
- монадические осмысления
- Альтернативное решение, такое как сообщение, питаящее nil
- (другие аспекты, которые я пропустил)
Ответы
Ответ 1
Я думаю, что краткое изложение того, почему null является нежелательным, состоит в том, что бессмысленные состояния не должны быть представлены.
Предположим, что я моделирую дверь. Он может находиться в одном из трех состояний: открыт, закрыт, но разблокирован, закрыт и заблокирован. Теперь я смогу смоделировать его вдоль линий
class Door
private bool isShut
private bool isLocked
и ясно, как отобразить мои три состояния в эти две булевы переменные. Но это оставляет четвертое, нежелательное состояние: isShut==false && isLocked==true
. Поскольку типы, которые я выбрал, так как мое представление допускает это состояние, я должен приложить умственные усилия, чтобы гарантировать, что класс никогда не попадает в это состояние (возможно, явно кодируя инвариант). Напротив, если бы я использовал язык с алгебраическими типами данных или проверенными перечислениями, которые позволяют мне определять
type DoorState =
| Open | ShutAndUnlocked | ShutAndLocked
то я мог бы определить
class Door
private DoorState state
и больше нет беспокойств. Система типов гарантирует, что существует только три возможных состояния для экземпляра class Door
. Это то, к чему подходят системы типов - явное исключение целого класса ошибок во время компиляции.
Проблема с null
заключается в том, что каждый ссылочный тип получает это дополнительное состояние в своем пространстве, которое обычно нежелательно. А переменной string
может быть любая последовательность символов, или это может быть это сумасшедшее дополнительное значение null
, которое не отображается в моей проблемной области. Объект Triangle
имеет три Point
s, которые сами имеют значения X
и Y
, но, к сожалению, сам Point
или Triangle
может быть этим безумным нулевым значением, которое бессмысленно для области графического отображения я Работает. Etc.
Когда вы намереваетесь моделировать возможное несуществующее значение, вы должны выбрать его явно. Если способ, которым я намереваюсь моделировать людей, состоит в том, что каждый Person
имеет FirstName
и LastName
, но только некоторые имеют MiddleName
s, тогда я хотел бы сказать что-то вроде
class Person
private string FirstName
private Option<string> MiddleName
private string LastName
где string
здесь предполагается непустым типом. Тогда нет никаких сложных инвариантов для установления и отсутствия неожиданного NullReferenceException
при попытке вычислить длину имени кого-то. Система типов гарантирует, что любой код, относящийся к MiddleName
, учитывает возможность его None
, тогда как любой код, относящийся к FirstName
, может с уверенностью предположить, что там есть значение.
Так, например, используя вышеприведенный тип, мы могли бы написать эту глупую функцию:
let TotalNumCharsInPersonsName(p:Person) =
let middleLen = match p.MiddleName with
| None -> 0
| Some(s) -> s.Length
p.FirstName.Length + middleLen + p.LastName.Length
без забот. Напротив, на языке с нулевыми ссылками для таких типов, как string, тогда предполагается
class Person
private string FirstName
private string MiddleName
private string LastName
вы в конечном итоге создаете такие файлы, как
let TotalNumCharsInPersonsName(p:Person) =
p.FirstName.Length + p.MiddleName.Length + p.LastName.Length
который взрывается, если входящий объект Person не имеет инварианта всего непустого или
let TotalNumCharsInPersonsName(p:Person) =
(if p.FirstName=null then 0 else p.FirstName.Length)
+ (if p.MiddleName=null then 0 else p.MiddleName.Length)
+ (if p.LastName=null then 0 else p.LastName.Length)
или, возможно,
let TotalNumCharsInPersonsName(p:Person) =
p.FirstName.Length
+ (if p.MiddleName=null then 0 else p.MiddleName.Length)
+ p.LastName.Length
предполагая, что p
обеспечивает первое/последнее, но среднее может быть нулевым, или, может быть, вы делаете проверки, которые генерируют разные типы исключений, или кто знает что. Все эти сумасшедшие варианты реализации и вещи, о которых нужно думать, возникают из-за того, что есть эта глупая представляемая ценность, которую вы не хотите или не хотите.
Нуль обычно добавляет ненужную сложность. Сложность является врагом всего программного обеспечения, и вы должны стремиться к уменьшению сложности всякий раз, когда это разумно.
(Обратите внимание, что для этих простых примеров сложнее, даже если FirstName
не может быть null
, string
может представлять ""
(пустую строку), что, вероятно, также не является имя человека, которое мы намереваемся моделировать. Таким образом, даже с непустыми значениями строк, все же может быть, что мы "представляем бессмысленные значения". Опять же, вы можете выбрать сражение с этим либо с помощью инвариантов и условного кода во время выполнения, или, используя систему типов (например, иметь тип NonEmptyString
). Последнее, возможно, не рекомендуется ( "хорошие" типы часто "закрываются" по набору общих операций, и, например, NonEmptyString
не закрывается над .SubString(0,0)
), но он демонстрирует больше точек в пространстве дизайна. В конце дня в любой системе определенного типа существует определенная сложность, с которой он будет очень хорошо справляться, и другая сложность, которая просто по сути сложнее Чтобы избавиться от этого. Ключом к этой теме является то, что почти в каждой системе типов изменение от "нулевых ссылок по умолчанию" до "non-nulla" по умолчанию "почти всегда является простым изменением, которое делает систему типов намного лучше в борьбе с сложностью и исключении определенных типов ошибок и бессмысленных состояний. Поэтому довольно сумасшествие, что многие языки повторяют эту ошибку снова и снова.)
Ответ 2
Хорошая вещь о типах опций заключается не в том, что они являются необязательными. Дело в том, что все остальные типы не.
Иногда мы должны иметь возможность представлять своеобразное "нулевое" состояние. Иногда мы должны представлять опцию "нет значения", а также другие возможные значения, которые может принимать переменная. Таким образом, язык, который категорически запрещает это, будет немного искалечен.
Но часто нам это не нужно, и разрешение такого "нулевого" состояния приводит только к двусмысленности и путанице: каждый раз, когда я обращаюсь к переменной ссылочного типа в .NET, я должен учитывать, что она может быть нулевой.
Часто он никогда не будет нулевым, потому что программист структурирует код так, что он никогда не может произойти. Но компилятор не может проверить это, и каждый раз, когда вы его видите, вы должны спросить себя: "Может ли это быть нулевым? Нужно ли здесь проверять значение null?"
В идеале, во многих случаях, когда null не имеет смысла, он не должен быть разрешен.
Это сложно сделать в .NET, где почти все может быть нулевым. Вы должны полагаться на автора кода, который вы называете, на 100% дисциплинированным и последовательным и четко документировали, что может и не может быть нулевым, или вам нужно быть параноидальным и проверить все.
Однако, если типы по умолчанию не имеют значения NULL, вам не нужно проверять, являются ли они нулевыми. Вы знаете, что они никогда не могут быть нулевыми, потому что проверка компилятора/типа обеспечивает это для вас.
И тогда нам просто нужен черный ход для редких случаев, когда нам нужно обрабатывать нулевое состояние. Затем можно использовать тип "option". Затем мы допускаем null в тех случаях, когда мы приняли сознательное решение о том, что нам нужно представить случай "нет значения", и в каждом другом случае мы знаем, что значение никогда не будет равно нулю.
Как упоминалось выше, в С# или Java, например, null может означать одну из двух вещей:
- переменная неинициализирована. В идеале это должно быть никогда. Переменная не должна существовать, если она не инициализирована.
- переменная содержит некоторые "необязательные" данные: она должна быть способна представить случай, когда данных нет. Иногда это необходимо. Возможно, вы пытаетесь найти объект в списке, и заранее не знаете, есть ли он там. Затем мы должны иметь возможность представить, что "объект не найден".
Второй смысл должен быть сохранен, но первый должен быть полностью исключен. И даже второе значение не должно быть значением по умолчанию. Это то, что мы можем выбрать, если и когда нам это нужно. Но когда нам не нужно что-то необязательное, мы хотим, чтобы контролер типа гарантировал, что он никогда не будет равен нулю.
Ответ 3
Все ответы до сих пор сосредоточены на том, почему null
- это плохо, и как это удобно, если язык может гарантировать, что определенные значения будут никогда равными нулю.
Затем они продолжают утверждать, что было бы довольно аккуратной идеей, если бы вы применяли ненулевое значение для всех значений, что можно сделать, если вы добавите концепцию типа Option
или Maybe
для представления типов, которые могут не быть всегда имеют определенное значение. Это подход, примененный Haskell.
Всё хорошее! Но это не исключает возможности использования явно нулевых/ненулевых типов для достижения такого же эффекта. Почему же Опция по-прежнему хорошая? В конце концов, Scala поддерживает значения с нулевым значением (имеет значение, поэтому он может работать с библиотеками Java), но также поддерживает Options
.
Q.. Каковы преимущества, выходящие за рамки возможности полностью удалять нули из языка?
A. Состав
Если вы делаете наивный перевод из кода с нулевым знанием
def fullNameLength(p:Person) = {
val middleLen =
if (null == p.middleName)
p.middleName.length
else
0
p.firstName.length + middleLen + p.lastName.length
}
для кода с поддержкой опций
def fullNameLength(p:Person) = {
val middleLen = p.middleName match {
case Some(x) => x.length
case _ => 0
}
p.firstName.length + middleLen + p.lastName.length
}
нет большой разницы! Но это также ужасный способ использования опций... Этот подход намного чище:
def fullNameLength(p:Person) = {
val middleLen = p.middleName map {_.length} getOrElse 0
p.firstName.length + middleLen + p.lastName.length
}
Или даже:
def fullNameLength(p:Person) =
p.firstName.length +
p.middleName.map{length}.getOrElse(0) +
p.lastName.length
Когда вы начинаете работать со списком параметров, он становится еще лучше. Представьте, что List people
сам по себе необязателен:
people flatMap(_ find (_.firstName == "joe")) map (fullNameLength)
Как это работает?
//convert an Option[List[Person]] to an Option[S]
//where the function f takes a List[Person] and returns an S
people map f
//find a person named "Joe" in a List[Person].
//returns Some[Person], or None if "Joe" isn't in the list
validPeopleList find (_.firstName == "joe")
//returns None if people is None
//Some(None) if people is valid but doesn't contain Joe
//Some[Some[Person]] if Joe is found
people map (_ find (_.firstName == "joe"))
//flatten it to return None if people is None or Joe isn't found
//Some[Person] if Joe is found
people flatMap (_ find (_.firstName == "joe"))
//return Some(length) if the list isn't None and Joe is found
//otherwise return None
people flatMap (_ find (_.firstName == "joe")) map (fullNameLength)
Соответствующий код с нулевыми проверками (или даже elvis?: операторами) будет болезненно длинным. Реальный трюк здесь - операция flatMap, которая позволяет вложенное понимание опций и коллекций таким образом, что значения, допускающие значение NULL, никогда не смогут достичь.
Ответ 4
Так как люди, кажется, не хватает его: null
неоднозначно.
Дата рождения Алисы null
. Что это значит?
Бит дата смерти - null
. Что это значит?
"Разумная" интерпретация может заключаться в том, что существует дата рождения Алисы, но неизвестна, тогда как Боб дата смерти не существует (Боб все еще жив). Но почему мы получили разные ответы?
Другая проблема: null
- это ребро.
- Является
null = null
?
- Есть
nan = nan
?
- Является
inf = inf
?
- Является
+0 = -0
?
- Является
+0/0 = -0/0
?
Ответы обычно "да", "нет", "да", "да", "нет", "да" соответственно. Сумасшедшие "математики" называют NaN "ничтожеством" и говорят, что он сравнивается с самим собой. SQL обрабатывает значения NULL как не равные чему-либо (поэтому они ведут себя как NaNs). Интересно, что происходит, когда вы пытаетесь сохранить ± ∞, ± 0 и NaN в одном столбце базы данных (есть 2 53 NaNs, половина из которых являются "отрицательными" ).
Чтобы ухудшить ситуацию, базы данных отличаются тем, как они обрабатывают NULL, и большинство из них несовместимы (см. Обработка NULL в SQLite для обзор). Это довольно ужасно.
И теперь для обязательной истории:
Недавно я разработал таблицу базы данных (sqlite3) с пятью столбцами a NOT NULL, b, id_a, id_b NOT NULL, timestamp
. Поскольку это общая схема, предназначенная для решения общей проблемы для довольно произвольных приложений, существует два ограничения уникальности:
UNIQUE(a, b, id_a)
UNIQUE(a, b, id_b)
id_a
существует только для совместимости с существующим дизайном приложения (отчасти потому, что я не придумал лучшего решения) и не используется в новом приложении. Из-за того, как NULL работает в SQL, я могу вставить (1, 2, NULL, 3, t)
и (1, 2, NULL, 4, t)
и не нарушать первое ограничение единственности (потому что (1, 2, NULL) != (1, 2, NULL)
).
Это работает специально из-за того, как NULL работает в ограничении уникальности для большинства баз данных (по-видимому, проще моделировать ситуации "реального мира", например, ни один человек не может иметь одинаковый номер социального обеспечения, но не все люди имеют один).
FWIW, не вызвав при этом поведения undefined, ссылки на С++ не могут "указывать на" null, и невозможно построить класс с неинициализированными ссылочными переменными-членами (если генерируется исключение, построение завершается с ошибкой).
Sidenote: Иногда вам могут понадобиться взаимоисключающие указатели (т.е. только один из них может быть не-NULL), например. в гипотетическом iOS type DialogState = NotShown | ShowingActionSheet UIActionSheet | ShowingAlertView UIAlertView | Dismissed
. Вместо этого я вынужден делать такие вещи, как assert((bool)actionSheet + (bool)alertView == 1)
.
Ответ 5
Нежелательность наличия ссылок/указателей по умолчанию может быть нулевой.
Я не думаю, что это основная проблема с нулями, основная проблема с нулями заключается в том, что они могут означать две вещи:
- Ссылка/указатель не инициализирована: проблема здесь такая же, как и изменчивость вообще. Во-первых, это затрудняет анализ вашего кода.
- Переменная, являющаяся нулевым, фактически означает что-то: это тот случай, когда типы опций фактически формализуются.
Языки, которые поддерживают типы опций, обычно также запрещают или препятствуют использованию неинициализированных переменных.
Как работают типы опций, включая стратегии для облегчения проверки нулевых случаев, таких как сопоставление шаблонов.
Чтобы быть эффективными, типы опций должны поддерживаться непосредственно на языке. В противном случае для имитации требуется много кода котельной. Совместимость шаблонов и тип-вывод - это две языковые функции, которые упрощают работу с типами параметров. Например:
В F #:
//first we create the option list, and then filter out all None Option types and
//map all Some Option types to their values. See how type-inference shines.
let optionList = [Some(1); Some(2); None; Some(3); None]
optionList |> List.choose id //evaluates to [1;2;3]
//here is a simple pattern-matching example
//which prints "1;2;None;3;None;".
//notice how value is extracted from op during the match
optionList
|> List.iter (function Some(value) -> printf "%i;" value | None -> printf "None;")
Однако на языке Java без прямой поддержки типов Option у нас будет что-то вроде:
//here we perform the same filter/map operation as in the F# example.
List<Option<Integer>> optionList = Arrays.asList(new Some<Integer>(1),new Some<Integer>(2),new None<Integer>(),new Some<Integer>(3),new None<Integer>());
List<Integer> filteredList = new ArrayList<Integer>();
for(Option<Integer> op : list)
if(op instanceof Some)
filteredList.add(((Some<Integer>)op).getValue());
Альтернативное решение, такое как сообщение, содержащее nil
Objective-C "message eating noil" - это не столько решение, сколько попытка облегчить головокружение нулевой проверки. В принципе, вместо того, чтобы бросать исключение во время выполнения при попытке вызвать метод для нулевого объекта, выражение вместо этого вычисляет нуль. Приостановив недоверие, он, как будто метод каждого экземпляра начинается с if (this == null) return null;
. Но тогда есть потеря информации: вы не знаете, вернул ли метод null, потому что он является допустимым возвращаемым значением, или потому, что объект фактически является нулевым. Это очень похоже на исключение проглатывания и не делает каких-либо успехов, устраняя проблемы с нулем, описанным ранее.
Ответ 6
Сборка привела нам адреса, также известные как нетипизированные указатели. C отображал их непосредственно в виде типизированных указателей, но вводил Algol null в качестве уникального значения указателя, совместимого со всеми типизированными указателями. Большая проблема с нулевым значением в C состоит в том, что, поскольку каждый указатель может быть нулевым, никогда нельзя использовать указатель без ручной проверки.
В языках более высокого уровня, имеющий нуль, неудобно, поскольку он действительно передает два разных понятия:
- Сообщаем, что что-то есть undefined.
- Указывает, что что-то необязательно.
Наличие переменных undefined в значительной степени бесполезно и приводит к поведению undefined, когда они происходят. Полагаю, что все согласятся, что во что бы то ни стало нужно избегать вещей undefined.
Второй случай является опциональным и лучше всего предоставляется явно, например, с параметром .
Скажем, мы в транспортной компании, и нам нужно создать приложение, чтобы помочь создать расписание для наших драйверов. Для каждого водителя мы храним несколько информации, таких как: водительские права, которые у них есть, и номер телефона для вызова в случае чрезвычайной ситуации.
В C мы могли бы:
struct PhoneNumber { ... };
struct MotorbikeLicence { ... };
struct CarLicence { ... };
struct TruckLicence { ... };
struct Driver {
char name[32]; /* Null terminated */
struct PhoneNumber * emergency_phone_number;
struct MotorbikeLicence * motorbike_licence;
struct CarLicence * car_licence;
struct TruckLicence * truck_licence;
};
Как вы заметили, при любой обработке по нашему списку драйверов нам нужно будет проверить нулевые указатели. Компилятор вам не поможет, безопасность программы зависит от ваших плеч.
В OCaml тот же код будет выглядеть так:
type phone_number = { ... }
type motorbike_licence = { ... }
type car_licence = { ... }
type truck_licence = { ... }
type driver = {
name: string;
emergency_phone_number: phone_number option;
motorbike_licence: motorbike_licence option;
car_licence: car_licence option;
truck_licence: truck_licence option;
}
Теперь скажем, что мы хотим напечатать имена всех драйверов вместе со своими номерами лицензий на грузовики.
В C:
#include <stdio.h>
void print_driver_with_truck_licence_number(struct Driver * driver) {
/* Check may be redundant but better be safe than sorry */
if (driver != NULL) {
printf("driver %s has ", driver->name);
if (driver->truck_licence != NULL) {
printf("truck licence %04d-%04d-%08d\n",
driver->truck_licence->area_code
driver->truck_licence->year
driver->truck_licence->num_in_year);
} else {
printf("no truck licence\n");
}
}
}
void print_drivers_with_truck_licence_numbers(struct Driver ** drivers, int nb) {
if (drivers != NULL && nb >= 0) {
int i;
for (i = 0; i < nb; ++i) {
struct Driver * driver = drivers[i];
if (driver) {
print_driver_with_truck_licence_number(driver);
} else {
/* Huh ? We got a null inside the array, meaning it probably got
corrupt somehow, what do we do ? Ignore ? Assert ? */
}
}
} else {
/* Caller provided us with erroneous input, what do we do ?
Ignore ? Assert ? */
}
}
В OCaml, который будет:
open Printf
(* Here we are guaranteed to have a driver instance *)
let print_driver_with_truck_licence_number driver =
printf "driver %s has " driver.name;
match driver.truck_licence with
| None ->
printf "no truck licence\n"
| Some licence ->
(* Here we are guaranteed to have a licence *)
printf "truck licence %04d-%04d-%08d\n"
licence.area_code
licence.year
licence.num_in_year
(* Here we are guaranteed to have a valid list of drivers *)
let print_drivers_with_truck_licence_numbers drivers =
List.iter print_driver_with_truck_licence_number drivers
Как вы можете видеть в этом тривиальном примере, в безопасной версии нет ничего сложного:
- Это терпение.
- Вы получаете гораздо лучшие гарантии, и никакая нулевая проверка не требуется вообще.
- Компилятор гарантирует, что вы правильно рассмотрели вариант
В то время как в C вы могли просто забыть нулевую проверку и бум...
Примечание: эти примеры кода не компилируются, но я надеюсь, что вы получили идеи.
Ответ 7
В Microsoft Research есть проект, который называется
SpeС#
Это расширение С# с не-нулевым типом и некоторым механизмом проверять ваши объекты на отсутствие нулевого, хотя IMHO, применяя конструкцию контракт может быть более подходящим и более полезным для многих неприятных ситуаций, вызванных нулевыми ссылками.
Ответ 8
Роберт Нистром предлагает хорошую статью здесь:
http://journal.stuffwithstuff.com/2010/08/23/void-null-maybe-and-nothing/
описывая его мыслительный процесс, добавляя поддержку отсутствия и отказа от его языка Magpie.
Ответ 9
Исходя из .NET-фона, я всегда думал, что null имеет смысл, это полезно. Пока я не узнал о структурах и как легко было работать с ними, избегая большого количества шаблонов. Тони Хоар, выступая в QCon London в 2009 году, извинился за изобретение нулевого ссылка. Процитировать его:
Я называю это своей ошибкой в миллиард долларов. Это было изобретение нулевого в 1965 году. В то время я разрабатывал первый всеобъемлющая система типов для ссылок в объектно-ориентированном языка (ALGOL W). Моя цель состояла в том, чтобы обеспечить использование всех ссылок должны быть абсолютно безопасными, при этом проверка выполняется автоматически компилятор. Но я не мог удержаться от соблазна положить нулевой ссылки, просто потому, что это было так легко реализовать. Это привело к бесчисленные ошибки, уязвимости и сбои системы, которые имеют вероятно, вызвало миллиард долларов боли и ущерба в последние сорок лет года. В последние годы ряд программных анализаторов, таких как PREfix и PREfast в Microsoft используется для проверки ссылок и предоставления предупреждения, если есть риск, что они могут быть не нулевыми. Более свежий Языки программирования, такие как SpeС#, ввели объявления для ненулевые ссылки. Это решение, которое я отклонил в 1965 году.
См. этот вопрос у программистов
Ответ 10
Я всегда смотрел на Null (или ноль) как отсутствие значения.
Иногда вы этого хотите, иногда нет. Это зависит от домена, с которым вы работаете. Если отсутствие имеет смысл: нет среднего имени, то ваше приложение может действовать соответствующим образом. С другой стороны, если нулевое значение не должно быть: первое имя равно null, тогда разработчик получает пресловутый 2 & телефонный звонок.
Я также видел, что код перегружен и слишком сложный с проверкой на null. Для меня это означает одну из двух вещей:
а) ошибка выше в дереве приложений
б) плохой/неполный дизайн
С положительной стороны - Null, вероятно, является одним из наиболее полезных понятий для проверки того, что что-то отсутствует, а языки без понятия null приводят к чрезмерному усложнению ситуации, когда придет время для проверки данных. В этом случае, если новая переменная не инициализирована, упомянутые язычники обычно устанавливают переменные в пустую строку, 0 или пустую коллекцию. Однако, если пустая строка или 0 или пустая коллекция допустимые значения для вашего приложения - тогда у вас есть проблема.
Иногда это обходит путем изобретения особых/странных значений для полей, представляющих неинициализированное состояние. Но что происходит, когда специальное значение вводится благими намерениями пользователя? И пусть не попадает в беспорядок, это будет делать процедуры проверки данных.
Если язык поддерживает нулевую концепцию, все проблемы исчезнут.
Ответ 11
Векторные языки иногда могут уйти, не имея нулевого значения.
В этом случае пустой вектор является типичным нулем.