ООП
Dart — объектно-ориентированный язык, так что данная парадигма здесь представлена схоже с другими языками. В данном параграфе мы обсудим особенности, которые могут показаться непонятными, но при внимательном рассмотрении помогают создавать лаконичный и структурированный код.
Конструктор Const
В предыдущем параграфе мы научились создавать примитивные константы, но Dart позволяет создавать константные объекты с помощью конструктора const
.
Давайте пробовать:
Запускаем и видим на этапе компиляции ошибку: Cannot invoke a non-'const' constructor where a const expression is expected.
Что она значит? На самом деле из неё мы можем почерпнуть два важных замечания:
- в переменную
const
можно записать только «константное» выражение, то есть мы не сможем присвоить ей результат выполнения функции; - в переменную
const
можно записать только экземпляр класса, созданный с помощью конструктораconst
.
Давайте по порядку. Для начала рассмотрим пример, объясняющий первый пункт. Здесь мы пытаемся записать в константную переменную результат вычисления функции, который будет известен только после запуска программы, даже если кажется, что результат будет всегда один:
Получаем ошибку: Const variables must be initialized with a constant value.
Она появляется, потому что в константные переменные нельзя записать значения, которые не известны до начала выполнения программы. Второе выражение нам говорит о необходимости использовать ключевое слово const
при создании объекта.
Давайте пробовать:
Ошибка никуда не ушла, потому что у класса Printer
нет конструктора const
. Пробуем его добавить. Синтаксис данного типа конструктора отличается от обычного только наличием const
в начале.
Все условия выполнены: есть конструктор const
и создаётся константный экземпляр. Однако возникает новая ошибка: Constructor is marked 'const' so all fields must be final.
Важно понимать, что в константную переменную нельзя записать значение, которое неизвестно до запуска программы или которое возможно изменить при исполнении программы.
Чтобы решить последнюю проблему, мы должны изменить код и затем проверить его.
И наконец мы добиваемся ожидаемого результата.
Конструкторы 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
. Давайте ниже рассмотрим, где может пригодиться этот инструмент.
- Можно определить специфический интерфейс для создания экземпляров:
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");
}
}
- Делегировать создание экземпляров подтипов конструкторам
factory
, что позволяет в определённой степени реализовать 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 нет ключевого слова interface
. Если быть точным, то изначально оно было, но в 2012 году разработчики его удалили, и взамен каждый класс в Dart имеет implicit interface.
В Dart есть только классы и их разновидности: mixin
и abstract class
. При дальнейшем объяснении мы будем ссылаться на это знание.
Давайте попробуем доработать пример с Printer
:
Теперь TaggedPrinter
реализует интерфейс Printer
с помощью ключевого слова implements
. Оно позволяет реализовать интерфейс другого класса без наследования его реализации. Implements
так же обязывает реализовать каждый элемент интерфейса суперкласса (родителя).
Dart позволяет реализовать множество интерфейсов в одном классе. Давайте попробуем добавить ещё один интерфейс FooInterface и реализовать его в Printer:
Сейчас на 6-й строчке мы видим ошибку. Она возникает из-за того, что в предыдущем примере мы создали переменную printer
типа PrinterInterface
. В интерфейсе PrinterInterface
нет метода foo()
, поэтому компилятор не знает, что на самом деле printer
умеет вызывать foo()
.
Правильным будет использовать вычисление типа или явно указать тип переменной — Printer
. В его интерфейсе уже есть и printValue()
, и foo()
.
Наследование
Прежде чем мы поговорим про расширение классов, важно остановиться на наследовании в Dart. В документации сказано, что в языке реализовано mixin-based наследование. Это значит, что каждый класс может иметь только один суперкласс. Это существенное ограничение, но язык предоставляет нам взамен способы расширять поведение класса, о которых пойдёт речь ниже.
Extends
Ключевое слово extends
позволяет расширить поведение класса через создание подкласса-наследника. При этом наследник может переопределять части интерфейса родителя и обязан задать свою реализацию для каждой нереализованной части. Рассмотрим пример с наследованием от абстрактного класса:
Данный пример работает и для расширения обычных классов, за исключением обязательной реализации абстрактных частей интерфейса — вы не сможете сделать обычный класс с нереализованными методами.
Давайте зафиксируем, что мы можем извлечь из этого примера:
- подкласс наследует поведение суперкласса;
- подкласс может переопределить поведение суперкласса;
- если у суперкласса есть конструктор, подкласс должен иметь конструктор, который вызывает суперконструктор родителя;
- интерфейс переменной определяет её тип, а реализацию определяет класс, который создаём.
Попробуем поменять тип переменной:
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
. А полные возможности extensions можно увидеть в документации.
With
and 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
уже подмешать не выйдет, так что ситуация с ромбовидным наследованием через примеси тоже исключена:
Поведение примеси не может быть расширено, но реализовывать другие интерфейсы 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
.
Чтобы лучше разобраться в этом, рассмотрим схему:
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
, тогда становится не ясно, какую реализацию в итоге выберет язык. Ещё непонятнее становится при следующей схеме наследования:
Если в расширяемых классах встречаются разные реализации одного и того же метода, Dart считает верной ту, что была объявлена последней, — будь то переопределение в самом классе или последний подмешанный Mixin
. Теперь, когда мы познакомились со всем инструментарием ООП В Dart, попробуйте решить несколько задач.
Попробуйте угадать, что вызовет следующий код:
BA
Типы работают так же, как и при наследовании классов.
Попробуйте понять, что выведет следующий код:
6 true