2.4. Dart: особенности языка

Dart — а что ты такое?

В 2011 году Dart появился как замена JavaScript. Это значит, что знакомый с web-программированием, найдёт схожие с JavaScript возможности в Dart. На сегодняшний день язык прошёл достаточно долгий путь, получил поддержку null-safety, статическую типизацию и Dart-VM.

Чаще всего его можно увидеть в паре с фреймворком Flutter в сфере разработки кроссплатформенных приложений, также сейчас есть перспективные попытки использовать Dart для написания серверов.

В этом параграфе поговорим про интересные возможности, отличающие Dart от других языков.

Переменные

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

Создание переменной мало чем отличается от других языков. Пример:

Код выше создаёт переменную типа String, присваивает ей значение Hello, world! и выводит результат в консоль.

var

Dart поддерживает вывод типов (type-inference), так что указывать тип переменной сразу необязательно. На помощь приходит ключевое слово var:

dynamic

В Dart есть ключевое слово dynamic, отключающее проверки типов для переменной. Им стоит пользоваться, если тип переменной не известен до запуска программы и его нельзя вывести. Давайте заменим var из предыдущего примера на dynamic и слегка модифицируем код:

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

Заметим, что в случае с var вычисление типа переменной всегда происходит на этапе компиляции. Следовательно, если переменная, помеченная var, не будет инициализирована при объявлении, то такая переменная будет иметь «неизвестный тип» (null), что позволит присвоить ей значение любого типа.

Рассмотрим описанное поведение на примере ниже:

final and const

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

final

Ключевое слово final запрещает переопределение переменной после инициализации.

Важно. Переменная, помеченная final, — не константа, просто её значение нельзя переопределить после инициализации.

Давайте создадим переменную final и попытаемся изменить её значение.

Действительно, переменная a получит значение 5, а при дальнейших попытках переприсвоить ей значение мы увидим исключение на этапе компиляции.

При этом можно модифицировать внутреннее состояние значения, присвоенного переменной final.

late

Ключевое слово late позволяет объявить переменную и не инициализировать её значение сразу.

Это полезно, когда хотим объявить переменную non-nullablenull-safety поговорим ниже), а значение ей задать позже.

//Без late будет исключение compile-time
late String name;

void main() {
  name = 'Dart';
  print('Hello, $name');
}

const

Ключевое слово const помечает константы. Их значения известны ещё на этапе компиляции, и их нельзя модифицировать или переопределять во время исполнения. Про запрет на модификацию поговорим ниже вместе с константными конструкторами классов.

А пока давайте заменим final на const в примере выше.

В итоге получаем сразу несколько ошибок до компиляции:

  1. Переменную const нельзя оставить неинициализированной;
  2. Переменную const нельзя переопределить;
  3. Переменной const нельзя изменить тип, что довольно очевидно и вытекает из предыдущего пункта и системы типов языка.

Теперь давайте решим задачу.

Задача 1

Создадим два списка const и final и попробуем добавить в них новые элементы. Что выведет программа?

void main() {
  final finalList = <int>[];
  finalList.add(3);
  print(finalList);

  const constList = <int>[];
  constList.add(2);
  print(constList);
}

{% cut "Ответ" %}
[3]

Ошибка — Unsupported operation

Коллекции

В этом блоке рассмотрим работу с коллекциями.

Объявление коллекций

При необходимости Dart позволяет указывать тип элементов в коллекциях.

final list = [1, "string", 2.4]; // любой тип
final strongTypedList = <int>[1, 2, "string"]; // только целочисленные значения, из-за "string" получаем ошибку на этапе компиляции

Обратите внимание: хотя указание типа не обязательно, —  компилятор вычислит его самостоятельно на основе элементов коллекции.

Он будет считать, что в внутри списка list лежат переменные dynamic. В итоге код не гарантирует Type-Safety.

Распаковка коллекций

Мы можем распаковать несколько коллекций в одну с помощью оператора ....

final collection1 = [1,2,3];
final collection2 = [4,5,6];
final compositeCollection = [...collection1, ...collection2];

If-else внутри коллекций

При объявлении коллекций мы можем внести в неё условия на добавление элементов.

Попробуйте переключить condition на false и посмотреть на результат.

Enhanced enums

В Dart есть знакомые многим enum-типы, объявить простейший enum можно так:

enum Color {red, green, blue}

В версии языка 2.17 разработчики добавили Enhanced enums.

Enhanced enum — класс со своими полями, методами и конструкторами, но с определёнными ограничениями:

  • Поля класса должны быть final, включая те, что подмешаны через with;
  • Все конструкторы должны быть константными;
  • Все factory-конструкторы могут вернуть только один из объявленных экземпляров Enum;
  • Нельзя переопределить index, hashCode или оператор равенства ==;
  • Нельзя использовать имя values для членов класса, потому что это конфликтует с геттером values из API стандартных enum;
  • В начале enum нужно объявить все возможные значения. При этом в целом значений должно быть не менее одного.

Enhanced enums могут пригодиться во многих случаях. К примеру, при работе с иконками:

enum MyIcon {
  close(path: 'assets/close', width: 200),
  car(path: 'assets/car', width: 300),
  driver(path: 'assets/driver', width: 300);

  const MyIcon({
    required this.path,
    required this.width,
  });

  factory MyIcon.withName(String name) =>
      values.firstWhere((v) => v.name.contains(name), orElse: () => close);

  final String path;
  final double width;

  String getThemedPath(bool isDarkTheme) =>
      isDarkTheme ? '${path}_night.webp' : '$path.webp';
}

Тернарный оператор

В определённых ситуациях if-else избыточен, тогда на помощь приходит тернарный оператор:

bool isEvenFull(int value) {
	bool result;

	if (value % 2 == 0) {
		result = true;
	} else {
		result = false;
	}

	return result;
}

//Может быть чуть короче
bool isEvenShort(int value) {
	return value % 2 == 0 ? true : false
}

typedef

Это ключевое слово позволяет переименовать какой-то тип.

typedef JSON = Map<String, Object?>;
typedef Converter = String Function(int value); // Что это такое? см. Функции

String convertToString(int value, Converter converter) => converter?.call(value);

class MyModel {
	final int value;

	MyModel(this.value);

  factory MyModel.fromJson(JSON json) => MyModel(json['value'] ?? 0);
}

Строки

Dart предоставляет обширный инструментарий для удобной работы со строками.

Как создать строку

В Dart есть несколько способов, чтобы создать строку:

  1. Через одинарные кавычки 'string';
  2. Через двойные кавычки "string";
  3. Через тройные кавычки — одинарные '''string''' или двойные """string""";
  4. Как «сырую» строку r'string';
  5. Через StringBuffer . В основном он нужен для манипуляций со строками.

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

На первый взгляд, последний пример с «сырой» строкой (r'Hello, world! I like dollar$$$$’) может показаться непонятным. Давайте разберёмся.

Например, $ — зарезервированный спецсимвол, и в строках, созданных с помощью кавычек, компилятор будет воспринимать его как символ интерполяции, и в финальную строку не добавит. Чтобы явно сказать компилятору, что в строке нет спецсимволов, нужно использовать сырые строки.

Интерполяция строк

Выше вы могли заметить использование символа $ при выводе значений. Это интерполяция строк — одна из возможностей языка, позволяющих манипулировать со строками.

В Dart есть три способа, чтобы объединить их в одну:

  1. Конкатенация — склеивание двух строк в одну;
  2. Интерполяция — встраивание в строку вычисленных значений;
  3. StringBuffer.

Давайте рассмотрим пример, показывающий, как получить одну и ту же строку всеми тремя способами:

Сильной разницы в производительности между ними нет, так что вы вольны выбирать именно тот, который удобнее в вашей ситуации.

Экранирование спецсимволов

Когда мы рассматривали способы, которыми создаются строки, то сказали, что можно «отключить» все спецсимволы в сырой строке. Но такой способ далеко не всегда нам нужен, зачастую мы хотим экранировать только пару символов из всей строки.

Достичь этого можно с помощью ещё одного спецсимвола — \.

final productCost = 400;

// Итог: Hi i'm Michael. This thing costs only $400
final constString = 'Hi i\'m Michael. This thing costs only \$$productCost';

Регулярные выражения

В Dart есть удобный механизм по работе с регулярными выражениями — RegExp.

Приведём пример, который проверит, есть ли в строке слово world:

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

Модификаторы доступа

В отличие от других языков, в Dart нет модификаторов доступа: public, private и protected.

Есть только возможность ограничить видимость переменной, функции или класса с помощью нижнего подчеркивания _.

Оно буквально ограничивает видимость за пределами файла, в котором была объявлена переменная или функция. Но есть исключение — нотации part of/part. На них не будем долго задерживаться, но скажем, что они позволяют разбить один файл «на части» с общей видимостью, как будто это один файл.

Например, мы можем ограничить видимость полей и методов класса. Так никто не сможет получить доступ к ним за пределами файла-объявления:

class MyClass{
	final _privateField = "Hi, im private";

	void _privateFunction(){
		print(_privateField);
	}
}

Чтобы лучше понять фразу «видимость за пределами контекста», давайте рассмотрим пример:

MyClassвместе с main() и SecondClass объявлены внутри одного файла, поэтому приватные части MyClass видны другим членам файла или контекста.

Если же кто-то попытается из другого файла вызвать _privateFunction(), то у него ничего не выйдет.

null-safety

Dart поддерживает sound null-safety как необязательную функцию на версиях с 2.12 по 2.19 и как обязательную часть с версии 3. Это значит, что разработчик защищен от NullReferenceException, так называемой «ошибки на миллиард долларов».

Подробнее о реализации null-safety в Dart можно прочитать в документации.

Мы же остановимся на основах.

С включенным null-safety в Dart все переменные не могут иметь значение null, если об этом не сказано явно с помощью ?:

String nonNullVariable = "Hello, world!";
// При попытке присвоить null будет ошибка
nonNullVariable = null;

String? nullableVariable = "Hello, world!";
// Теперь ничего не мешает присвоить null
nullableVariable = null;

Сам компилятор постоянно следит за разработчиком и подсвечивает потенциально опасные места:

Код просто не скомпилируется, если компилятор увидит потенциально опасный вызов.

Выходов из ситуации несколько:

  1. Оператор ?.;
  2. Оператор !.;
  3. Вычисление типа компилятором.

Оператор ? или null-aware

В опасных местах можно пользоваться null-aware. Он буквально говорит компилятору: «Если переменная не null, то вызывай код, иначе — верни null».

final value = myClass?.foo();

Компилятор ругаться перестанет, и мы получим следующее:

Тип valueint?.

myClass не null — value == 5.

myClass не присвоено или null — value == null

Оператор ! или force unwrap

Данный оператор говорит компилятору: «Я уверен, что данное значение не может быть null».

final value = myClass!.foo();
// Чуть длиннее. Осторожно, force unwrap и приведение это не одно и то же!
final value = (myClass as MyClass).foo();

Компилятор ругаться перестанет, и мы получим следующее:

  • Тип valueint.
  • myClass присвоено не null — value == 5.
  • myClass не присвоено или null — код в рантайме выбросит NullReferenceException.

Стоит пользоваться с осторожностью. Force unwrap лишает нас кода null-safe, так что в итоге можно не заметить потенциальную NRE. Предпочтительнее использовать ?. и ??.

Вычисление типа компилятором

Мы можем положить nullable-значение в локальную переменную и специально проверить эту локальную переменную на null.

final myList = <MyClass?>[MyClass(), null];
final firstElement = myList.first; // first == myList[0]

int? value;
if(firstElement != null){
	value = firstElement.foo();
}

В итоге получим следующее:

Тип valueint? или тот, что вы указали при объявлении (ex. dynamic);

Первый элемент не null — value == 5;

Первый элемент null — value == null.

Dart не видит, что первый элемент коллекции существует. Так происходит из-за full sound safety, компилятор может гарантировать вычисление только для локальных переменных.

За подробностями можно обратиться к документации или видео от разработчиков.

Бонус

В качестве бонуса в языке Dart есть ещё несколько операторов:

?? — оператор null. Выполнит код справа-налево, только если значение левого операнда null.

getIntegerOrZero(null); // 0
getIntegerOrZero(5); // 5

int getIntegerOrZero(int? integer) {
	return interger ?? 0;
}

?... — оператор null-spread. Распакует коллекцию, только если она не null.

final list = [1, 2, 3];
final List<String>? nullableList;
final myGreatList = [...list, ?...nullableList];

??= — оператор null-aware assignment. Присвоит значение переменной слева, только если она сейчас null.

  int value; // null
  value ??= 5; // 5
  value ??= 6; // value != null -> value все еще 5

Функции

Dart, следуя за тенденциями, предоставляет большой инструментарий по работе с функциями. Рассмотрим его в данном параграфе.

Параметры функции

Все виды параметров условно можно распределить на следующей диаграмме.

fluttern

А теперь по порядку.

positional — параметры функции.

Их всегда нужно передать, даже если один из них nullable (то есть необязательный):

void foo(int value, String? value1) {}

void main() {
	foo(5, null);
}

optional positional — параметры, которые необязательно передавать при вызове. Но нельзя, чтобы у таких параметров non-nullable не было значения.

void foo(int value, [String value1]) {} // Это ошибка, у value1 гарантированно должно быть значение

// У optional параметров могут быть значения по-умолчанию. Важно: значение default — всегда константа
void foo(int value, [String value1 = '']) {}

// Либо можно сделать их nullable, тогда их значение по умолчанию — null
void foo(int value, [String? value1]) {}

void main() {
	foo(5);
}

named — параметры, которые позволяют создавать функции с огромным количеством параметров и не ошибиться при вызове.

void foo1(int value1, bool flag, int value2, int value3) {} // неудобно и сложно

void foo2({int value1, bool flag, int value2, int value3}) {} // удобно и читаемо

void main() {
	foo1(5, true, 2, 1);

	foo2(value1: 5, flag: true, value2: 2, value3: 3);
}

В коде выше foo2 объявлена с ошибкой, поскольку мы можем не передать один из параметров при вызове. При этом значение у параметра не может отсутствовать. На помощь приходит ключевое слово required — оно обязывает при вызове передать именованный параметр:

void foo2({required int value1, required bool flag, required int value2, required int value3}) {} // удобно и читаемо

void main() {
	foo2(value1: 5, flag: true, value2: 2); // ошибка, value3 не передали
}

Так же, как и с positional-параметрами, мы можем задать значения по умолчанию для именованного параметра, тогда можно отказаться от required и не передавать его при вызове:

void foo2({required int value1, required bool flag, required int value2, int value3 = 0}) {} // удобно и читаемо

void main() {
	foo2(value1: 5, flag: true, value2: 2); // ошибки нет, value3 == 0
}

Функции верхнего уровня и переменные

Это понятие носит много имён: глобальные функции, функции высшего порядка, «просто» функции. Для удобства мы ниже будем называть их функциями верхнего уровня. Но суть у них одна — они и переменные могут существовать вне контекста какого-то класса. Простейший пример такой функции — main(). Это не только точка входа в программу, но ещё и функция верхнего уровня.

// Функция верхнего уровня
void topLevelfunc() {}

// Переменная верхнего уровня
int topLevelVariable = 5;

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

Стрелочные функции

Стрелочные функции позволяют описывать функции в одну строку:

int getInteger() => 5; // Код вернет 5

// 1. Стрелочная функция может и не возвращать какого-то конкретного значения
// 2. А void может быть опущен
// И то, и другое - плохой тон, хоть и так можно.
printInteger() => print(getInteger());

// Ниже эквивалентный код без стрелочных функций
int getInteger() {
	return 5;
}

printInteger() {
	print(getInteger());
}

Анонимные функции

Из функционального подхода в Dart перекочевали анонимные функции — их можно воспринимать как объект типа Function.

У типа Functionесть метод call, он и есть вызов самой функции. Его полезно использовать, если переменная-функция может быть null.

typedef PrinterFunc = String Function(String name, int years);

void main() {
 PrinterFunc? expression = (String name, int years) => "Hello, my name is $name, i'm $years";

  print(expression?.call('Daniil', 20));
}

Каскадный вызов методов

Функция, позволяет вызывать последовательно методы одной переменной:

final list = [1, 2, 3];
print(list..removeLast()..remove(0)); // 2

Результат каскадного вызова — исходное значение. Чтобы это лучше понять, рассмотрим следующий пример: toLowerCase() и toUpperCase() возвращают изменённое значение value, но при каскадном вызове программа не выведет результат этих функций.

final String value = 'Hello, world!';
print(value..toUpperCase()..toLowerCase()); // 'Hello, world!' - исходное значение value
print(value.toUpperCase().toLowerCase()); // 'hello, world!' - результат toLowerCase()

Замыкания

Этот механизм позволяет анонимной функции, объявленной внутри другой, получать доступ к вышестоящему «контексту».

Звучит страшно, так что рассмотрим пример замыкания:

Функция верхнего уровня robotFactory возвращает анонимную функцию типа RobotBuild. Сама анонимная функция формирует строку из переданного name и доступных из контекста robotFactory переменных model и specifications.

Генераторы

В Dart есть синхронные и асинхронные генераторы. Здесь мы расскажем про синхронные, а асинхронные рассмотрим чуть позже, в параграфе «Dart Concurrency, изоляты».

Синхронные генераторы, например, пригодятся, когда вам нужно сгенерировать много тестовых данных:

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.3. Ресурсы приложения
Следующий параграф2.5. Dart: ООП