
Всем привет!
Сегодня я хочу обратить ваше внимание на такой важный концепт в программировании, как null, поведать о его истории и выяснить, как Dart решает проблему работы с null.
Начнем с сухого определения. Null - это специальное значение или состояние, которое обозначает отсутствие информации или данных в переменной, объекте или месте, где оно используется. Null фактически представляет собой отсутствие значения.
Какова же роль такого значения?
Тут стоит зайти несколько издалека. Дело в том, что до появления концепции null или аналогичных механизмов, ссылки в низкоуровневых языках программирования могли содержать так называемый “мусор” (garbage) или произвольные значения, если они не были явно инициализированы. Это происходило из-за особенностей управления памятью. И когда такое случалось, программа могла вести себя непредсказуемым образом: возникали серьезные ошибкам, сбои или даже утечки данных. Что разумеется, не могло никого устраивать.
И вот здесь на помощь приходит концепция null. Она помогает избежать непредсказуемого поведения программы, которое может возникнуть, если ссылка не имеет явного значения и указывает на произвольную область памяти. Однако стоит отметить, что null также может быть источником ошибок, если не используется осторожно. Проблемы с null включают в себя забывание проверки на null, неправильное использование и допущение ошибок в коде. Именно по этим причинам некоторые языки программирования исследуют альтернативные методы обработки отсутствия значения, чтобы уменьшить риски ошибок и сделать код более безопасным и предсказуемым.
Пример кода на C:
#include <stdio.h> int main() { int* ptr; // Не инициализирован - содержит произвольный адрес printf("Адрес в ptr: %p\n", (void*)ptr); // В 99% случаев это упадет, но теоретически может и не упасть: *ptr = 42; return 0; }
В C/C++ неинициализированный указатель содержит мусорный адрес. Это может привести к неуловимым багам. Программа то падает, то работает.
История возникновения Null
История возникновения null довольно увлекательная, поскольку его создатель - Энтони Хоар (английский ученый в области информатики, который также известен разработкой алгоритма "быстрой сортировки") спустя почти 50 лет выступил с презентацией с говорящим названием - "Null References: The Billion Dollar Mistake". Со слов Хоар, он придумал null, поскольку это было самое простое и очевидное решение в обработке ситуаций с указанием на произвольную область памяти. В начале презентации он подчеркивает, что множество ошибок и сбоев в программном обеспечении связаны с null-ссылками. Собственно поэтому презентация так и называется.

Пробежимся по самым основным негативным последствиям, связанным с null:
Отсутствие значения: null обычно используется для указания на отсутствие значения или объекта. Это означает, что переменная, содержащая null, не имеет конкретного значения или ссылки на объект. Это может привести к ситуации, когда код пытается обратиться к этой переменной или объекту, которого на самом деле нет, что вызывает ошибку.
Неявная проверка: Во многих случаях программисты могут забывать явно проверять переменные на null перед их использованием. Это может привести к ситуации, когда код пытается обратиться к null, что вызывает исключение или ошибку времени выполнения.
Неявное преобразование: В некоторых языках программирования, null может неявно преобразовываться в другие типы данных. Это также может вызвать неожиданное поведение программы, если программист не учел этот момент.
Сложность отладки: Ошибки, связанные с null, могут быть сложными для выявления и исправления. Поскольку null может быть присвоено переменной в разных местах кода, найти источник ошибки может быть непросто.
Непредсказуемое поведение: Использование null может привести к непредсказуемому поведению программы в зависимости от ее текущего состояния. Это может затруднить разработку и поддержку кода.
Итак, проблема с null заключается в том, что его использование может создавать ситуации, в которых программный код работает непредсказуемо, и это может привести к ошибкам времени выполнения, которые могут быть трудными для обнаружения и устранения. Поэтому Хоар завершает лекцию призывом к программистам и разработчикам языков программирования задуматься о проблеме null и стремиться к поиску более безопасных и надежных способов работы с отсутствием значения. И разработчики Dart не стали исключением, поскольку они разработали механизм Null Safety.
Null в Dart
В языке Dart изначально существовал null и он использовался для обозначения отсутствия значения или непроинициализированной переменной.
Стоит отметить, что такое значение могло возникнуть в результате любого обращения к типу, поскольку прежняя система типов это позволяла.

Из данной иерархии становится ясно, что это было возможно поскольку Null являлся подтипом всех типов. В силу этого null мог с легкостью проскочить в программу и вызвать различные исключения.
Пример:
bool isEmpty(String string) => string.length == 0; void main() { isEmpty(null); // Упадет с NoSuchMethodException }
Что естественно является совершенно не безопасным. Поэтому команда Dart решила не оставаться в стороне и следуя примерам таких языков, как Kotlin, Swift, Rust и многих других, разработала собственную концепцию - Null Safety. Данная концепция призвана обезопасить разработчиков от ошибок, возникающих в результате непреднамеренного доступа к переменным, для которых установлено значение null. И основа ее работы заключается в том, что она изменила все потенциальные ошибки времени исполнения на так называемые edit-time ошибки.
Это означает, что анализатор будет еще на этапе работы с кодом заставлять вас:
обязательно задавать значение переменным, в которых значение null не предусмотрено;
и соответственно запрещать задавать значение null таким переменным.
И чтобы этого достичь, команда Dart следовала определенным принципам:
Код должен быть безопасным по умолчанию: Это означает, что написание кода, используя только лишь Null Safety, не должно привести к исключениям, связанным с null;
Код, следующий принципам Null Safety, должен быть легок в написании: Ничего слишком нового в синтаксис языка не добавляется, а следовательно, он останется таким же простым в написании.
Код, получившийся в результате следования Null Safety, должен быть абсолютно надежным: В этом принципе даются гарантии, что уже написанный код с использованием Null Safety, если только вы сами не обходите эти правила, должен быть надежным.
Но при этом стоит понимать, что устранение null не является самой задачей команды Dart, поскольку нет ничего плохого с самим null. Он достаточно удобен, если мы хотим указать отсутствие значение, например, в опциональных параметрах, когда пользователь имеет право не передавать их полностью. Как сказано в документации: "Плохо не то, что null существует, а то, что ошибки, связанные с ним, могут возникнуть там, где вы этого не ожидаете." И чтобы минимизировать такие неожиданные места, Dart снабжает нас определенными инструментами и предоставляет нам обновленную систему типов.
Особенности Null Safety в Dart
Новая система типов в Dart выглядит следующим образом:

Здесь мы видим, что Null перестал быть подтипом всех типов и вынесен в отдельное ветку. Таким образом, Dart в корне решает проблему доступа к null, поскольку теперь все типы по умолчанию не nullable.
Однако, как мы уже говорили, Null вовсе не бесполезен. Существуют ситуации, когда он может быть необходим. Рассмотрим пример из документации:
void makeCoffee(String coffee, [String? dairy]) { if (dairy != null) { print('$coffee with $dairy'); } else { print('Black $coffee'); } }
Следующая запись в параметрах String? объявляет так называемые “nullable” типы. Об этом несколько позже, но если в двух словах, такой тип как бы может описывать одно из двух значений: либо String, либо Null. И благодаря этому у нас есть возможность оставить этот параметр пустым, из-за чего по умолчанию он будет равняться Null, и мы сделаем необходимую нам обработку.
Теперь давайте подробнее рассмотрим Nullable типы.

Данная запись типа с вопросительным знаком под капотом создает, так называемый Union тип.
Типы Union
Типы Union - это концепция в программировании, которая позволяет переменным хранить значения разных типов. Они называются "Union" (или объединенными) типами, потому что они объединяют несколько типов в один. Это полезно, когда вам нужно, чтобы переменная могла содержать разные виды данных.
Концепция типов Union имеет свои корни в функциональных языках программирования, таких как Haskell и ML. В этих языках, тип Union был важной частью системы типов. Они были разработаны для обеспечения безопасности типов и обработки данных разных форм.
С течением времени идея типов Union распространилась и в другие языки программирования, и Dart не исключение. Разные языки имеют разные способы реализации типов Union.
В Dart, вы можете использовать библиотеки для создания типов Union, например, библиотеку sealed_unions:
import 'package:sealed_unions/sealed_unions.dart'; class MyUnionType extends Union2Impl<int, String> { MyUnionType._(Union2<int, String> union) : super(union); factory MyUnionType.intType(int value) => MyUnionType._(Union2<int, String>.first(value)); factory MyUnionType.stringType(String value) => MyUnionType._(Union2<int, String>.second(value)); }
Но с версии 3.0 Dart представил sealed классы, и теперь подобные структуры можно создавать следующим образом:
void main() { // Создает Null значение var nullableString1 = NullableString.nullValue(); // Создает String значение final nullableString2 = NullableString.stringValue('Hello, world!'); // Проверки на Null if (nullableString1 is NullValue) { print('nullableString1 is null'); } if (nullableString2 is StringValue) { print('nullableString2 is a String with value: ${nullableString2.value}'); } if (nullableString2 is StringValue) { final value = nullableString2.value; // Какая-нибудь обработка value } // Меняет с Null на String nullableString1 = NullableString.stringValue('fdsdf'); } sealed class NullableString { const NullableString(); factory NullableString.nullValue() = NullValue; factory NullableString.stringValue(String value) = StringValue; } class NullValue extends NullableString { const NullValue() : super(); } class StringValue extends NullableString { final String value; const StringValue(this.value) : super(); }
Но это уже совсем другая история...
Вернемся к nullable типам. Итак, мы выяснили, что nullable представляют собой Union типы. Касательно них нужно усвоить еще два момента:
Компилятор Dart позволяет присваивать
Stringзначения переменным типаString?без явного приведения типов, потому чтоString?является более общим типом, который включает в себяString. Однако обратное не справедливо: вы не можете безопасно присвоить переменнойStringзначениеString?, не используя явное приведение типов или проверку на null;String? nullableString = 'lolkek'; String string = nullableString; // Анализатор будет брехаться // Нужно сделать явное приведение String string = nullableString!;Любой вызов методов на nullable типы не возможен. Исключения:
toString,hashCode,==void bad(String? maybeString) { print(maybeString.length); // Анализатор будет брехаться // А здесь все четко! final isNull = maybeString == null; }
Чтобы избежать изнурительных if-проверок, мы можем использовать оператор ? в Dart.
String notAString = null; print(notAString?.length); // Вывод: null
Оператор ? позволяет избежать ошибок, связанных с доступом к методам и свойствам объекта, если этот объект равен null. Таким образом, если notAString равен null, то notAString?.length вернет null, а не вызовет ошибку.
Также оператор ? можно использовать в цепочке. До Dart 2.12, чтобы избежать ошибок, приходилось делать проверки для каждого вызова в цепочке:
String? notAString = null; print(notAString?.length?.isEven);
Однако, начиная с Null Safety, нам достаточно одной проверки:
String? notAString = null; print(notAString?.length.isEven);
Теперь, учитывая все ранее описанное, стоит взглянуть на финальную иерархию:

Если вам понадобится использовать тип, который охватывает все Nullable типы, теперь вместо Object вы должны использовать Object?. Таким образом, вы включите все Nullable типы в список подходящих типов.
void main() { String? nullable; String nonNullable = 'non-nullable'; if (nullable is Object?) { print('nullable branch'); } if (nonNullable is Object?) { print('non-nullable branch'); } }
Тип Never может быть полезен в очень редких случаях, когда нужно обозначить, что метод никогда не вернет значение. Это может быть полезно, например, когда метод генерирует исключение:
Never wrongType(String type, Object value) { throw ArgumentError('Expected $type, but was ${value.runtimeType}.'); }
Нам осталось обсудить еще две немаловажных темы, которые стали актуальны с появлением NullSafety. Это Flow analysis и late переменные.
Flow Analysis (Анализ потока управления) в Dart
Анализ потока управления - это важный аспект компиляции и оптимизации кода, который обычно скрыт от пользователей, но играет важную роль в обеспечении безопасности и производительности программ.
В Dart с появлением Null Safety был введен анализ потока управления для обеспечения безопасности типов. Это означает, что компилятор Dart теперь может анализировать код и понимать, какие типы могут быть в определенных точках выполнения программы. Это позволяет компилятору выявлять потенциальные ошибки связанные с null и типами на этапе компиляции, что делает код более безопасным и предсказуемым.
Вот пример использования анализа потока управления в Dart:
bool isEmptyList(Object object) { if (object is List) { return object.isEmpty; // Анализ потока управления понимает, что object является List, и допускает вызов isEmpty. } else { return false; } }
Обратите внимание, как на отмеченной строке мы можем вызвать isEmpty у Object. Этот метод определен в List, а не в Object. Это работает, потому что в Dart если в ветке if проверяется тип c использованием ключевого слова is, и если это проверка вернет true нам будут доступны методы и свойства этого типа. С появлением Null Safety теперь подобные проверки можно делать и на null. Если в ветке проверяемый окажется не null, то мы вправе без явного приведения вызывать все его методы и свойства.
int stringLength1(String? stringOrNull) { return stringOrNull.length; // error stringOrNull may be null } int stringLength2(String? stringOrNull) { if (stringOrNull != null) return stringOrNull.length; // ok return 0; }
Late
С появлением Null Safety в Dart появилось ключевое слово late, которое используется для отложенной инициализации переменных. Это означает, что вы можете объявить переменную и присвоить ей значение позже, чем она была объявлена. Основными случаями использования late являются:
Использование
lateс полями класса:class Coffee { late String _temperature; void heat() { _temperature = 'hot'; } void chill() { _temperature = 'iced'; } String serve() => _temperature + ' coffee'; }Отложенная инициализация (
lazy):class Weather { late int _temperature = _readThermometer(); }
Когда вы так пишите, инициализация становится ленивой. То есть данное поле проинициализируется не при создании экземпляра класса, а при его непосредственном использовании.Это может быть полезно если мы имеем дело с трудоемкими операциями, вычисление которых хорошо бы отложить.
Заключение

Null Safety в Dart - это функциональность, введенная в язык программирования Dart, которая призвана предотвратить ошибки, связанные с отсутствием значения (null) в вашем коде. Эта функциональность включает в себя аннотации, которые позволяют объявить, будет ли переменная содержать null или нет. Это улучшает безопасность кода, так как многие ошибки, связанные с нулевыми указателями, могут быть обнаружены на этапе компиляции, а не во время выполнения программы.
В итоге, использование Null Safety в Dart не только увеличивает надежность и безопасность вашего кода, но также помогает сделать разработку более эффективной и удовлетворительной, что делает эту функциональность важной частью современной разработки на этом языке.
