Featured image of post Nullable или не nullable?

Nullable или не nullable?

Пожалуй, одна из самых типичных проблем при составлении схем данных - стоит ли объявлять атрибуты с null-значениями? Стоит ли все атрибуты всегда делать nullable или это может привести к неприятным последствиям? В этой статье я постараюсь рассмотреть возможные проблемы nullable-атрибутов.

Проблемы типизации

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

К общим типам (примитивам) относят

  • булевы типы boolean
  • целочисленные типы integer
  • вещественные числа float
  • строковое типы string

К составным типам относят

  • массивы array
  • объекты object

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

В некоторых языках реализовано автоматическое преобразование и приведение типов. Это имеет свои преимущества и недостатки. С одной стороны, удобство проявляется в том, что программисту можно не указывать явные преобразования (как в предыдущем примере со строкой и числом) и код выглядит более компактным и читаемым. С другой стороны, программа может вести себя не так, как задумано. Например, если числа складывать со строками, то строки могут быть преобразованы к 0 и вероятно результатом работы будет совершенно не то, что задумывал программист.

Кроме самих типов есть еще одно специальное значение - null. В компилируемых языках оно означает, что под переменную не выделена область памяти и указатель равен пустому значению. В реляционных базах данных значение NULL говорит о том, что ячейка таблицы не заполнена (оставлена пустой). В динамических языках, таких как JavaScript или PHP это значение говорит о том, что переменная не заполнена. Соответственно, к проблеме типизации еще добавляется проблема null-значений. Например, в языке со строгой типизацией попытка сложить null с числом приведет к фатальной ошибке (т.к. не получится разыменовать указатель в памяти). В языке со слабой типизацией null будет приведено к 0 или пустой строке, но это не всегда может означать, что это то, что хотел бы видеть программист. Поэтому при работе с nullable переменными всегда следует иметь ввиду что же означает null и обрабатывать поведение программы вручную.

Потенциальные ошибки в программе, связанные с типизацией

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

Соглашения о структурах данных

В крупных проектах для согласования работы между разными частями кода важно использовать соглашения о структурах данных. Например, при разделении веб-системы на клиентскую часть (frontend) и серверную (backend) соглашением может служить формат работы серверного API. В настоящее время наибольшую популярность приобрел формат JSON. Поэтому в целях улучшения взаимодействия frontend и backend, как правило, договариваются о структуре данных, используемых атрибутах и типах для их значений и закрепляют это в документации.

Кроме самих типов еще нужно договориться о том, можно ли использовать значение null для атрибутов. Это довольно важная часть соглашения, т.к. значение null может по-разному интерпретироваться на frontend и backend и неправильный выбор может вести к неприятным последствиям.

Null и примитивные типы

Предположим, что в некоторой структуре JSON есть поле “описание”, которое представляет собой строку.

1
2
3
{
    "description": "string"
}

Гипотетически у этого поля могут быть два изначальных состояния - “не заполнено” и “пустое”. Состояние “не заполнено” можно выразить значением null, а состояние “пустое” с помощью пустой строки "". И тут важно задаться вопросом: а есть ли между этими значениями принципиальная разница с точки зрения бизнес-логики? Если этой разницы нет, то состояние “не заполнено” можно считать идентичным состоянию “пустое” и выражать его с помощью пустой строки "". В противном случае, если разрешить значение null, то во всем коде всегда нужно будет учитывать поведение программы в случае равенства атрибута description значению null.

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

1
2
var description = "this is description"
console.log("Description: " + description)

выведет в консоль строку Description: this is description. Но если значение description равно null, то в консоли выведется строка Description: null. Поэтому правильнее было бы написать следующий код.

1
2
3
4
5
if (description == null) {
    description = ""
}

console.log("Description: " + description)

Если не сделать операции, указанной в условном блоке, то в зависимости от языка, значение null может быть приведено к пустой строке "", а может к строковому представлению "null" (как в случае с JavaScript). В последнем случае пользователь, чаще всего далекий от программирования, увидит непонятное ему значение “null”. Более того, если это значение попадет в форму редактирования, то оно будет сохранено. А это вряд ли то значение, которое хотел бы сохранить пользователь.

Так как код пишут разные программисты, то программист ответственный за обработку формы может не знать о том, что атрибут description может принимать значение null и забыть добавить условный блок. Если же договориться о том, что в состоянии “не заполнено” атрибут принимает значение пустой строки и запретить значение null (например, при валидации формы на backend), то это снижает риск ошибок с null-значениями.

Рассмотрим подобный случай с числами. Предположим, что в объекте описываются габариты какого-либо объемного предмета.

1
2
3
4
5
{
    "width": 123,
    "height": 456,
    "length": 789
}

Есть ли у атрибутов ширины, высоты и длины какой-то смысл в значении null с точки зрения бизнес-логики? Чем оно отличается от значения 0? Чаще всего разницы между null и 0 нет и значение null лучше всего запретить. В противном случае, к примеру, в компилируемом языке Golang придется работать с указателями и расчет объема предмета превращается из такого кода

1
2
3
4
5
6
7
8
9
type Item struct {
    Width  int
    Height int
    Length int
}

func Volume(item Item) int {
    return item.Width * item.Height * item.Length
}

в такой

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Item struct {
    Width  *int
    Height *int
    Length *int
}

func Volume(item Item) int {
    width := 0
    height := 0
    length := 0

    if item.Width != nil {
        width = item.Width*
    }
    if item.Height != nil {
        width = item.Height*
    }
    if item.Length != nil {
        length = item.Length
    }

    return width * height * length
}

В качестве контрпримера, когда значение null оправдано, можно привести объект с диапазоном значений. Предположим, что в системе есть объекты, которые описывают диапазоны с вещественными значениям “от” from и “до” to.

1
2
3
4
{
    "from": 1.25,
    "to": 5.75
}

С точки зрения бизнес-логики есть правило, которое говорит о том, что диапазоны могут быть частичными, т.е. в объекте может отсутствовать одно из значений from или to. При этом значение 0 - это существующее значение, отличающееся по смыслу от значения “не заполнено”. То есть объект

1
2
3
4
{
    "from": 0,
    "to": 5.75
}

эквивалентен по смыслу диапазону “от 0 до 5,75”, а объект

1
2
3
4
{
    "from": null,
    "to": 5.75
}

эквивалентен по смыслу диапазону “до 5,75”. В этом случае использование null оправдано и важно с точки зрения бизнес-логики. Значения 0 и null несут разный смысл.

Null и массивы

Еще один интересный пример - массивы. Если в случае чисел и строк большинство динамических языков могу приводить значения null к 0 или пустой строке "" и часто это может сочетаться с логикой программы, то значение null у атрибута типа массив array однозначно приводит к появлению блоков кода такого вида (пример на PHP).

1
2
3
4
5
if ($items !== null) {
    foreach ($items as $item) {
        doStuff($item);
    }
}

Соответственно, если между frontend и backend в контракте закреплено, что у массива может быть значение null, то следует ожидать, что во многих местах на backend (в сложном проекте эти массивы могут передаваться между различными сервисами - ввода данных, экспорта, логирования, сбора статистики и т.п.) придется не забывать учитывать, что у атрибута items значение может быть равно null. А это может вести к багам, т.к. не всегда программисты могут знать об этом специальном значении и учитывать его. К тому же это сильно загрязняет код и усложняет его восприятие. В итоге все может придти к такому виду.

null value for array in API

Null и объекты

Одной из самых печально известных проблем в программировании считается проблема null pointer exception. Тони Хоар, известный учёный в области информатики (создатель алгоритма быстрой сортировки, один из разработчиков ALGOL), в своём выступлении признаёт, что изобретение null-ссылок в 1965 году стало его “ошибкой на миллиард долларов”.

“Я называю это своей ошибкой на миллиард долларов… Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние 40 лет” (Тони Хоар).

Суть проблемы: null-ссылки (например, null в Java, nil в Swift) — это ссылки, которые явно указывают на “ничто”. Они позволяют обозначать отсутствие значения, но их неконтролируемое использование приводит к ошибкам времени выполнения (например, NullPointerException), которые сложно предсказать на этапе компиляции.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Неинициализированная ссылка, по умолчанию null (в Java)
String str;
System.out.println(str.length()); // NullPointerException

// Явное присвоение null
Object obj = null;
obj.toString(); // NullPointerException

// Метод возвращает null
List<String> list = getList(); // Предположим, что getList() вернул null
list.add("test"); // NullPointerException

Как результат, ошибки, связанные с null, становятся частой причиной сбоев, уязвимостей и падений программ. На их поиск, исправление и профилактику тратятся огромные ресурсы (отсюда “миллиард долларов” — метафора совокупных убытков индустрии).

Для решения проблем null-ссылок существуют разные способы.

  • Использование типов-обёрток (например, Optional в Java, Option в Scala), которые явно указывают на возможность отсутствия значения.
  • Null-безопасные языки (например, Kotlin), где переменные по умолчанию не могут быть null, а для “nullable”-типов требуется явная проверка.
  • Паттерны проектирования, исключающие null (например, возврат пустых коллекций вместо null).

Вывод: Тони Хоар призывает сообщество учиться на его ошибке и проектировать языки и системы так, чтобы исключить саму возможность возникновения null-ошибок на уровне типов или архитектуры, а не перекладывать ответственность на разработчика.

Выводы и рекомендации

  • Тщательно продумывайте типы данных, которые используете в соглашениях.
  • Вводите возможность указывать значения null только у тех атрибутов, для которых это имеет смысл с точки зрения бизнес-логики.
  • В качестве смыслового значения “не заполнено” предпочтительнее вместо null использовать “пустое” значение, т.е.
    • 0 для числовых типов данных,
    • пустую строку "" для строковых типов,
    • пустой массив [] для массивов.
  • Избегайте использования возможности указывать null для атрибутов типа массив.
  • По возможности проектируйте типы объектов на основе паттернов проектирования, которые исключают null.
Создано при помощи Hugo
Тема Stack, дизайн Jimmy