Пожалуй, одна из самых типичных проблем при составлении схем данных - стоит ли объявлять атрибуты с 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 есть поле “описание”, которое представляет собой строку.
|
|
Гипотетически у этого поля могут быть два изначальных состояния - “не заполнено” и “пустое”. Состояние “не заполнено” можно выразить значением null
, а состояние “пустое” с помощью пустой строки ""
. И тут важно задаться вопросом: а есть ли между этими значениями принципиальная разница с точки зрения бизнес-логики? Если этой разницы нет, то состояние “не заполнено” можно считать идентичным состоянию “пустое” и выражать его с помощью пустой строки ""
. В противном случае, если разрешить значение null
, то во всем коде всегда нужно будет учитывать поведение программы в случае равенства атрибута description
значению null
.
В качестве примера рассмотрим гипотетически код на языке JavaScript, который должен отображать описание description
пользователю системы. Такой код
|
|
выведет в консоль строку Description: this is description
. Но если значение description
равно null
, то в консоли выведется строка Description: null
. Поэтому правильнее было бы написать следующий код.
|
|
Если не сделать операции, указанной в условном блоке, то в зависимости от языка, значение null
может быть приведено к пустой строке ""
, а может к строковому представлению "null"
(как в случае с JavaScript). В последнем случае пользователь, чаще всего далекий от программирования, увидит непонятное ему значение “null”. Более того, если это значение попадет в форму редактирования, то оно будет сохранено. А это вряд ли то значение, которое хотел бы сохранить пользователь.
Так как код пишут разные программисты, то программист ответственный за обработку формы может не знать о том, что атрибут description
может принимать значение null
и забыть добавить условный блок. Если же договориться о том, что в состоянии “не заполнено” атрибут принимает значение пустой строки и запретить значение null
(например, при валидации формы на backend), то это снижает риск ошибок с null-значениями.
Рассмотрим подобный случай с числами. Предположим, что в объекте описываются габариты какого-либо объемного предмета.
|
|
Есть ли у атрибутов ширины, высоты и длины какой-то смысл в значении null
с точки зрения бизнес-логики? Чем оно отличается от значения 0
? Чаще всего разницы между null
и 0
нет и значение null
лучше всего запретить. В противном случае, к примеру, в компилируемом языке Golang придется работать с указателями и расчет объема предмета превращается из такого кода
|
|
в такой
|
|
В качестве контрпримера, когда значение null
оправдано, можно привести объект с диапазоном значений. Предположим, что в системе есть объекты, которые описывают диапазоны с вещественными значениям “от” from
и “до” to
.
|
|
С точки зрения бизнес-логики есть правило, которое говорит о том, что диапазоны могут быть частичными, т.е. в объекте может отсутствовать одно из значений from
или to
. При этом значение 0
- это существующее значение, отличающееся по смыслу от значения “не заполнено”. То есть объект
|
|
эквивалентен по смыслу диапазону “от 0 до 5,75”, а объект
|
|
эквивалентен по смыслу диапазону “до 5,75”. В этом случае использование null
оправдано и важно с точки зрения бизнес-логики. Значения 0
и null
несут разный смысл.
Null и массивы
Еще один интересный пример - массивы. Если в случае чисел и строк большинство динамических языков могу приводить значения null
к 0
или пустой строке ""
и часто это может сочетаться с логикой программы, то значение null
у атрибута типа массив array
однозначно приводит к появлению блоков кода такого вида (пример на PHP).
|
|
Соответственно, если между frontend и backend в контракте закреплено, что у массива может быть значение null
, то следует ожидать, что во многих местах на backend (в сложном проекте эти массивы могут передаваться между различными сервисами - ввода данных, экспорта, логирования, сбора статистики и т.п.) придется не забывать учитывать, что у атрибута items
значение может быть равно null
. А это может вести к багам, т.к. не всегда программисты могут знать об этом специальном значении и учитывать его. К тому же это сильно загрязняет код и усложняет его восприятие. В итоге все может придти к такому виду.
Null и объекты
Одной из самых печально известных проблем в программировании считается проблема null pointer exception. Тони Хоар, известный учёный в области информатики (создатель алгоритма быстрой сортировки, один из разработчиков ALGOL), в своём выступлении признаёт, что изобретение null-ссылок в 1965 году стало его “ошибкой на миллиард долларов”.
“Я называю это своей ошибкой на миллиард долларов… Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние 40 лет” (Тони Хоар).
Суть проблемы: null-ссылки (например, null в Java, nil в Swift) — это ссылки, которые явно указывают на “ничто”. Они позволяют обозначать отсутствие значения, но их неконтролируемое использование приводит к ошибкам времени выполнения (например, NullPointerException), которые сложно предсказать на этапе компиляции.
|
|
Как результат, ошибки, связанные с null, становятся частой причиной сбоев, уязвимостей и падений программ. На их поиск, исправление и профилактику тратятся огромные ресурсы (отсюда “миллиард долларов” — метафора совокупных убытков индустрии).
Для решения проблем null-ссылок существуют разные способы.
- Использование типов-обёрток (например, Optional в Java, Option в Scala), которые явно указывают на возможность отсутствия значения.
- Null-безопасные языки (например, Kotlin), где переменные по умолчанию не могут быть null, а для “nullable”-типов требуется явная проверка.
- Паттерны проектирования, исключающие null (например, возврат пустых коллекций вместо null).
Вывод: Тони Хоар призывает сообщество учиться на его ошибке и проектировать языки и системы так, чтобы исключить саму возможность возникновения null-ошибок на уровне типов или архитектуры, а не перекладывать ответственность на разработчика.
Выводы и рекомендации
- Тщательно продумывайте типы данных, которые используете в соглашениях.
- Вводите возможность указывать значения
null
только у тех атрибутов, для которых это имеет смысл с точки зрения бизнес-логики. - В качестве смыслового значения “не заполнено” предпочтительнее вместо
null
использовать “пустое” значение, т.е.0
для числовых типов данных,- пустую строку
""
для строковых типов, - пустой массив
[]
для массивов.
- Избегайте использования возможности указывать
null
для атрибутов типа массив. - По возможности проектируйте типы объектов на основе паттернов проектирования, которые исключают null.