2.6. Dart: ООП

Автор: Даниил Артамонов

Dart — объектно-ориентированный язык, так что эта парадигма здесь реализована схоже с другими языками.

В этой статье мы обсудим особенности ООП в Dart, которые на первый взгляд могут показаться непонятными:

  • Конструктор const.
  • Конструкторы factory.
  • Интерфейсы.
  • Наследование и расширение классов.

Если как следует разобраться в них, они помогут создавать лаконичный и структурированный код.

Рассмотрим их все по порядку.

Конструктор Const

В предыдущей статье мы научились создавать примитивные константы, но Dart позволяет создавать константные объекты с помощью конструктора const.

Давайте пробовать:

void main() {
  const printer = Printer('Hello, World!');
}

class Printer {
  final String _valueToPrint;

  Printer(this._valueToPrint);

  void printValue() {
    print('Printing: $_valueToPrint');
  }
}

Результат выполнения

main.dart:2:19: Error: Cannot invoke a non-'const' constructor where a const expression is expected.
Try using a constructor or factory that is 'const'.
  const printer = Printer('Hello, World!');
                  ^^^^^^^

Запускаем и видим на этапе компиляции ошибку: Cannot invoke a non-'const' constructor where a const expression is expected.

Что она значит? На самом деле из неё мы можем почерпнуть два важных замечания:

  1. в переменную const можно записать только «константное» выражение, то есть мы не сможем присвоить ей результат выполнения функции;
  2. в переменную const можно записать только экземпляр класса, созданный с помощью конструктораconst.

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

void main() {
  const printer = getInteger();
}

int getInteger() => 1;

Результат выполнения

main.dart:2:19: Error: Method invocation is not a constant expression.
  const printer = getInteger();
                  ^^^^^^^^^^

Получаем ошибку: Const variables must be initialized with a constant value. Она появляется, потому что в константные переменные нельзя записать значения, которые не известны до начала выполнения программы. Второе выражение нам говорит о необходимости использовать ключевое слово const при создании объекта.

Давайте пробовать:

void main() {
  const printer = const Printer('some String value');
}

class Printer {
  String valueToPrint;

  Printer(this.valueToPrint);

  void printValue() {
    print('Printing: $valueToPrint');
  }
}

Результат выполнения

main.dart:2:25: Error: Cannot invoke a non-'const' constructor where a const expression is expected.
Try using a constructor or factory that is 'const'.
  const printer = const Printer('some String value');
                        ^^^^^^^

Ошибка никуда не ушла, потому что у класса Printer нет конструктора const. Пробуем его добавить. Синтаксис данного типа конструктора отличается от обычного только наличием const в начале.

void main() {
  const printer = const Printer('some String value');
}

class Printer {
  String valueToPrint;

  const Printer(this.valueToPrint);

  void printValue() {
    print('Printing: $valueToPrint');
  }
}

Результат выполнения

main.dart:8:9: Error: Constructor is marked 'const' so all fields must be final.
  const Printer(this.valueToPrint);
        ^
main.dart:6:10: Context: Field isn't final, but constructor is 'const'.
  String valueToPrint;
         ^

Все условия выполнены: есть конструктор const и создаётся константный экземпляр. Однако возникает новая ошибка: Constructor is marked 'const' so all fields must be final.

Примечание

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

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

void main() {
  const printer = const Printer('some String value');
  printer.printValue();
}

class Printer {
  final String _valueToPrint;

  const Printer(this._valueToPrint);

  void printValue() {
    print('Printing: $_valueToPrint');
  }
}

Результат выполнения

Printing: some String value

И наконец мы добиваемся ожидаемого результата.

Конструкторы factory

Ещё одна отличительная черта Dart — в нём нельзя переопределять функции, методы и конструкторы.

Следующий код просто не скомпилируется:

int foo() {
	return 5;
}

int foo(int value) {
	return value * 2;
}

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

class Mathematic {
  int compute() {
    return 5;
  }

  int compute(int value) {
    return value * value;
  }

  int compute(int left, int right) {
    return left * right;
  }

  String compute(int left, int right, String operator) {
    return '$left$operator$right';
  }
}

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

  1. Если нужно определить специфический интерфейс для создания экземпляров.
  2. Если нужно реализовать Union-тип.

Покажем на примерах.

Пример №1: определяем специфический интерфейс для создания экземпляров

class Logger {
  String? _tag;
  
  Logger(this._tag);
  
  factory Logger.withBeautifulTag(String tag) => Logger('[$tag]');
  
  
  factory Logger.withDoubleTag(String tag) => Logger('$tag$tag');
  
  void log(Object? message) {
    print("$_tag:$message"); 
  }
}

Пример №2: реализуем Union-тип

Union-тип — объединение нескольких типов под одним. В Dart нет полноценной поддержки Union-типов, но реализовать такую концепцию возможно и существующими средствами. Или вы можете воспользоваться сторонними пакетами — например, Freezed.

Пример Union-типа на конструкторах factory:

// Тип A — это Union Type 
class A {
	// Экземпляр A создать нельзя
  A._();
	
  // Но мы можем вместо этого с помощью конструкторов класса A
  // создать экземпляры классов B и C 
  factory A.withB(int bValue) = B;
  factory A.withC(String cValue) = C;
}

class B extends A {
  final int bValue;

  B(this.bValue) : super._();
}

class C extends A {
  final String cValue;
  C._(this.cValue) : super._();

  factory C(String value) => C._(value);
}

Также для таких конструкторов можно использовать ключевое слово const.

class A {
  const A();
	// const factory-конструктор A.asConstFactory() вызывает конструктор const B()
	const factory A.asConstFactory() = B;
}

class B extends A {
  const B() : super();
}

Интерфейсы

В Dart каждый класс неявно определяет интерфейс (implicit interface), который могут реализовывать другие классы.

Интерфейс отражает контракт (набор публичных членов) класса — но не его реализацию. Если класс B реализует интерфейс класса A, B обязуется реализовать все публичные члены A, даже если не наследует код.

Давайте попробуем доработать пример с Printer:

void main() {
  Printer printer = Printer();
  Printer taggedPrinter = TaggedPrinter('MyTag');

  printer.printValue('some value');
  taggedPrinter.printValue('some value');
}

class Printer {
  void printValue(String value) {
    print('Printed: $value');
  }
}

class TaggedPrinter implements Printer {
  final String _tag;

  const TaggedPrinter(this._tag);

  @override
  void printValue(String value) {
    print('$_tag: $value');
  }
}

Результат выполнения

Printed: some value
MyTag: some value

Теперь TaggedPrinter реализует интерфейс Printer с помощью ключевого слова implements. Оно позволяет реализовать интерфейс другого класса без наследования его реализации. Implements так же обязывает реализовать каждый элемент интерфейса суперкласса (родителя).

Dart позволяет реализовать множество интерфейсов в одном классе. Давайте попробуем добавить ещё один интерфейс FooInterface и реализовать его в Printer:

void main() {
  PrinterInterface printer = Printer('Hello, world!');

  printer.printValue();

  // Ошибка появляется, потому что PrinterInterface "не знает" про метод foo()
  printer.foo();

  Printer correctPrinter = Printer('Hello, correct!');
  correctPrinter.printValue();
  correctPrinter.foo();
}

abstract class PrinterInterface {
  void printValue();
}

abstract class FooInterface {
  void foo();
}

class Printer implements PrinterInterface, FooInterface {
  final String _valueToPrint;

  const Printer(this._valueToPrint);

  @override
  void printValue() {
    print('Printing: $_valueToPrint');
  }

  void foo() {
    print('Fooing');
  }
}

Результат выполнения

main.dart:7:11: Error: The method 'foo' isn't defined for the type 'PrinterInterface'.
 - 'PrinterInterface' is from 'main.dart'.
Try correcting the name to the name of an existing method, or defining a method named 'foo'.
  printer.foo();
          ^^^

Сейчас на 6-й строчке мы видим ошибку. Она возникает из-за того, что в предыдущем примере мы создали переменную printer типа PrinterInterface. В интерфейсе PrinterInterface нет метода foo(), поэтому компилятор не знает, что на самом деле printer умеет вызывать foo().

Правильным будет использовать вычисление типа или явно указать тип переменной — Printer. В его интерфейсе уже есть и printValue(), и foo().

Наследование

Прежде чем мы поговорим про расширение классов, важно остановиться на наследовании в Dart. В документации сказано, что в языке реализовано наследование на основе mixin (англ. mixin-based inheritance, подробнее о нём поговорим в этой статье, но чуть позже).

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

Ключевое слово extends

Ключевое слово extends позволяет расширить поведение класса через создание подкласса-наследника. При этом наследник может переопределять части интерфейса родителя и обязан задать свою реализацию для каждой нереализованной части. Рассмотрим пример с наследованием от абстрактного класса:

void main() {
  final myObject = MyClass('String value');

  myObject.foo();
  myObject.bar();
  myObject.baz();
  myObject.myPrint();
}

abstract class MyAbstractClass {
  final String someValue;

  MyAbstractClass(this.someValue);

  void foo() {
    print('MyAbstractClass foo: $someValue');
  }

  void bar();

  void baz() {
    print('MyAbstractClass baz: $someValue');
  }
}

class MyClass extends MyAbstractClass {
  MyClass(super.someValue);
  // Или MyClass(String someValue):super(someValue); - это эквивалентные записи

  @override
  void bar() {
    print('MyClass bar: $someValue');
  }

  @override
  void baz() {
    print('MyClass baz: $someValue');
  }

  void myPrint() {
    print('My class print: $someValue');
  }
}

Результат выполнения

MyAbstractClass foo: String value
MyClass bar: String value
MyClass baz: String value
My class print: String value

Данный пример работает и для расширения обычных классов, за исключением обязательной реализации абстрактных частей интерфейса — вы не сможете сделать обычный класс с нереализованными методами.

Давайте зафиксируем, что мы можем извлечь из этого примера:

  1. подкласс наследует поведение суперкласса;
  2. подкласс может переопределить поведение суперкласса;
  3. если у суперкласса есть конструктор, подкласс должен иметь конструктор, который вызывает суперконструктор родителя;
  4. интерфейс переменной определяет её тип, а реализацию определяет класс, который создаём.

Попробуем поменять тип переменной:

void main() {
	// Теперь тип myObject - MyAbstractClass
  final MyAbstractClass myObject = MyClass('String value');

  myObject.foo();
  myObject.bar();
  myObject.baz();
	// IDE и компилятор подсветят здесь ошибку, потому что у MyAbstracClass нет метода myPrint()
  myObject.myPrint();
}

Методы расширения (extension)

Методы расширения — очень полезный инструмент языка. Они позволяют расширять поведение классов без создания подклассов и Utils-классов. Методы расширения имеют полный доступ к публичному интерфейсу класса, на который пишутся.

Ниже представлен пример использования extension:

void main() {
  print('23'.hasOneSymbol()); //false
}

extension StringExt on String { 
  bool hasOneSymbol() => length == 1;
}

// Тоже самое можно написать с помощью Utils-класса
class StringUtils {
	static bool hasOneSymbol(String value) => value.length == 1;
}

Таким образом, нам не придётся писать классы Utils со статическими методами для расширения поведения класса String. А полные возможности extension можно увидеть в документации.

Ключевое слово with и класс Mixin

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

Команда Dart старается по максимуму защитить разработчика от совершения ошибок — такая схема спасает от проблем множественного наследования. Например, от ромбовидного наследования.

Mixin — любой класс без конструктора и не наследующийся от других классов.

Вот это Mixin:

class MyMixin {
  void foo() {
    print('Mixed foo');
  }
}

И это Mixin:

abstract class MyAbstractMixin {
  void bar();
}

Их можно подмешать в новый класс с помощью with:

class MyMixin {
  void foo() {
    print('Mixed foo');
  }
}

abstract class MyAbstractMixin {
  void bar();
}

class A with MyMixin, MyAbstractMixin {
  @override
	/// bar обязаны реализовать, потому что это абстрактный метод
  void bar() {
    // TODO: implement bar
  }
  
}

А теперь MyMixin уже не Mixin, а обычный класс:

class B {}

class MyMixin extends B {
  void foo() {
    print('Mixed foo');
  }
}

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

fluttern

Поведение примеси не может быть расширено, но реализовывать другие интерфейсы Mixin может:

class MyMixin implements MyAbstractMixin {
  void foo() {
    print('Mixed foo');
  }

  @override
	// Мы обязаны реализовать абстрактный bar, т. к. MyMixin — это обычный класс
  void bar() {
    // TODO: implement bar
  }
}

// У интерфейса класса A появились два метода — foo(), bar()
// А реализацию он отнаследовал от примеси MyMixin
class A with MyMixin {

}

В примерах выше MyMixin можно по-прежнему использовать как обычный класс. Так, мы можем создавать его экземпляры и расширять. Но задача Mixin — расширять поведение без создания новых классов. На помощь приходит ключевое слово mixin:


mixin MyMixin implements MyAbstractMixin {
  void foo() {
    print('Mixed foo');
  }
 // Реализовывать абстрактный bar уже не обязательно, потому что экземпляр mixin не может быть создан. 
 // Следовательно, и гарантировать реализацию всех членов интерфейса не обязательно
}

// У интерфейса класса A появились два метода — foo(), bar()
// А реализацию он отнаследовал от примеси MyMixin
class A with MyMixin {
@override
  void bar() {
    // TODO: implement bar
  }
}

А ещё мы можем ограничить спектр классов, в которые Mixin можно подмешать с помощью ключевого слова on. Оно разрешает расширять наследников суперкласса с помощью Mixin.

Чтобы лучше разобраться в этом, рассмотрим схему:

fluttern

class Super {
  final int a;
  
  Super(this.a);
  
  void foo() {
    print('Super foo');
  }
}

mixin MyMixin on Super {
  void baz() {
    print('Mixed baz');
  }
}

// Класс А —  наследник Super и подмешивает Mixin. MyMixin — тоже наследник Super. 
class A extends Super with MyMixin {
  A(super.a);
}

// А вот уже в класс B подмешать MyMixin не получится.
class B with MyMixin {
  
}

В примере выше мы можем переопределить foo() внутри MyMixin, тогда становится не ясно, какую реализацию в итоге выберет язык. Ещё непонятнее становится при следующей схеме наследования:

fluttern

Если в расширяемых классах встречаются разные реализации одного и того же метода, Dart считает верной ту, что была объявлена последней, — будь то переопределение в самом классе или последний подмешанный Mixin. Теперь, когда мы познакомились со всем инструментарием ООП В Dart, попробуйте решить несколько задач.

Попробуйте угадать, что вызовет следующий код:

class P {
  String getMessage() => 'P';
}

mixin A {
  String getMessage() => 'A';
}

mixin B {
  String getMessage() => 'B';
}

class AB extends P with A, B {}

class BA extends P with B, A {}

void main() {
  String message = '';

  AB ab = AB();

  message += ab.getMessage();

  BA ba = BA();

  message += ba.getMessage();

  print(message);
}

Результат выполнения

BA
Ответ

BA

Типы работают так же, как и при наследовании классов.

Попробуйте понять, что выведет следующий код:

// ignore_for_file: unnecessary_type_check

class P {
  String getMessage() => 'P';
}

mixin A {
  String getMessage() => 'A';
}

mixin B {
  String getMessage() => 'B';
}

class AB extends P with A, B {}

class BA extends P with B, A {}

void main() {
  AB ab = AB();
  print(ab is P);
  print(ab is A);
  print(ab is B);

  BA ba = BA();
  print(ba is P);
  print(ba is A);
  print(ba is B);
}

Результат выполнения

true
true
true
true
true
true
Ответ

6 true

Модификаторы классов

Начиная с Dart 3, появились модификаторы классов (class modifiers), которые позволяют управлять, как класс могут наследовать, реализовывать или расширять из других библиотек.

Модификатор / комбинация

Можно инстанцировать?

Можно на­следовать (extends)?

Можно реализовывать (implements)?

Примечание

class (без модификатора)

да

да

да

полная свобода

abstract class

нет

да

да

традиционный абстрактный класс

interface class

да

нет (в других библиотеках)

да

предназначен для реализации API

abstract interface class

нет

нет (в других библиотеках)

да

чистый интерфейс

base class

да

да

нет (в других библиотеках)

класс, допускающий наследование, но не реализацию

abstract base class

нет

да

нет

абстрактный базовый класс

final class

да

нет

нет

нельзя наследовать и реализовывать

sealed class

нет

нет (в других библиотеках)

нет

ограничивает иерархию внутри библиотеки

Подробнее про модификаторы класса можно узнать в документации.

Отлично, с ООП разобрались. Чтобы закрепить знания, советуем пройти квиз ниже.

И скорее переходите к следующему статье — в нём мы подробно разберём асинхронность в Dart.