ООП Часть 2 в Dart

Принципы ООП

Принципы ООП

Абстракция - это выделение только важных характеристик объекта.

Например, у телефона основные свойства — это возможность звонить и принимать звонки. Его внутреннее устройство нас не интересует.

Абстракция телефона

Наследование позволяет одному классу (дочернему) перенимать свойства и методы другого класса (родительского).

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

Наследование смартфонов

Инкапсуляция — это сокрытие внутренней реализации объекта.

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

Инкапсуляция

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

Например, разные модели телефонов могут совершать вызов по-разному.

Полная схема принципов ООП

Абстракция

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

  1. Создадим в проекте папку character
  2. Добавим файл character.dart, где будут находится только классы
  3. Добавим файл main.dart, где будет находится главная функция main
Структура проекта

В нашем RPG мире все персонажи обладают базовыми способностями: атаковать и защищаться.
Имеют базовые характеристики имя, количество жизней, уровень.

Вместо того чтобы прописывать эти способности каждому типу (рыцари, маги, лучники), выделим общую абстракцию — базовый класс, описывающий основные функции любого персонажа.

Пропишем все классы внутри файла characters.dart

Dart - Абстрактный класс

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int hp; 
  final int level;
  
  AbstractCharacter(this.name, this.hp, this.level);

  void attack();
  void defend();
}
Объяснение абстрактного класса

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

Методы без тела: Абстрактный метод (например, attack) будет реализован позже, в классах-наследниках.

Теперь, любой персонаж может быть создан на основе этой абстракции

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

Схема наследования Иерархия классов

Используем extends чтобы унаследовать характеристики от другого класса

Файл characters.dart

Dart - Класс Warrior

Светлая тема Темная тема
// Класс Warrior наследуется от AbstractCharacter
class Warrior extends AbstractCharacter {
  final int strength; // Сила воина

  // Новый удобный синтаксис конструктора через super
  Warrior(super.name, super.level, super.hp, this.strength);

  // Переопределяем метод атаки
  @override
  void attack() {
    print("Воин $name атакует врага с силой $strength!");
  }

  // Переопределяем метод защиты
  @override
  void defend() {
    print("Воин $name ставит щит и защищается!");
  }
}
  1. class Warrior extends AbstractCharacter означает что Warrior унаследует все характеристики AbstractCharacter
  2. final int strength Уникальное свойство для класса Warrior
  3. Warrior(super.name, super.level, super.hp, this.strength) вызов конструктора при наследовании. Обязательно нужно сначала вызывать конструктор родителя, потом конструктор наследника (через super). Чтобы инициализировать свойства родительского класса, а потом уже класса потомка (через this).
  4. @override void attack() переопределяем родительские методы, т.е. берем методы родителя с тем же названием, но переделываем внутренний функционал, меняем форму (вид полиморфизма)

Далее попробуем создать новый объект класса Warrior и посмотрим какие у него теперь есть свойства и методы.

Файл main.dart

Dart - Использование Warrior

Светлая тема Темная тема
import 'characters.dart'; // 👈👈👈 импортируем файл для доступа к Warrior

void main() {
  Warrior knight = Warrior("Арагорн", 100, 80, 20);

  print(knight.name); // Арагорн
  print(knight.hp); // 100

  knight.attack(); // Воин Арагорн атакует врага с силой 20!
  knight.defend(); // Воин Арагорн ставит щит и защищается!
}
Доступные свойства и методы

Можно обратить внимание, что кроме характеристик объекта, которые мы придумали сами, здесь есть ещё, например, hashCode или toString()

Откуда они взялись?

Класс Object и глубокое наследование

Object

У каждого объекта в языке Dart, существует, один главный, но по умолчанию невидимый, родительский класс Object

От класса Object наследуются общие методы, такие как hashCode и toString()

Таким образом, даже у нашего абстрактного базового класса AbstractCharacter есть "супер-родитель" — класс Object

Иерархия Object

Переопределим метод toString для отображения информации о всех наших будущих персонажей

Файл characters.dart

Dart - Переопределение toString

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int level;
  final int hp;

  AbstractCharacter(this.name, this.hp, this.level);

  void attack();
  void defend();

  @override
  String toString() {
    super.toString();
    return "Имя=$name, уровень=$level, здоровье=$hp";
  }
}

Теперь реализуем более глубокое наследование с помощью персонажа Мага

Файл characters.dart

Dart - Класс Mage

Светлая тема Темная тема
// Класс Mage наследуется от AbstractCharacter
class Mage extends AbstractCharacter {
  int mana; // Магическая энергия

  Mage(super.name, super.hp, super.level, this.mana);

  // Метод для применения заклинания
  void castSpell(int manaCost) {
    if (mana <= 0 || mana < manaCost) {
      print("Не могу колдовать! Мана закончилась! Ааааа!");
      return;
    }
    print("$name кастует фаербол, затрачивая $manaCost маны!");
    this.mana -= manaCost;
  }

  @override
  void attack() {
    print("Маг $name атакует врага магическим кинжалом");
  }

  @override
  void defend() {
    print("Маг $name кастует защитный купол");
  }
}

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

Файл characters.dart

Dart - Классы DarkMage и LightMage

Светлая тема Темная тема
class DarkMage extends Mage {
  String darkPower = "Испепеление тьмой";

  DarkMage(super.name, super.hp, super.level, super.mana);

  void useDarkPower() {
    print("$name использует $darkPower");
    print("Всё в радиусе 100км было уничтожено!");
  }
}

class LightMage extends Mage {
  String healPower = "Исцеление";

  LightMage(super.name, super.hp, super.level, super.mana);

  void useLightPower() {
    print("$name использует $healPower");
    print("Теперь всё хорошо!");
  }
}
Полная иерархия классов

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

Файл main.dart

Dart - Использование магов

Светлая тема Темная тема
import 'characters.dart';

void main() {
  DarkMage darkWizard = DarkMage("Паймон", 200, 50, 1200);
  darkWizard.attack();
  darkWizard.defend();
  darkWizard.castSpell(20);
  darkWizard.useDarkPower();

  LightMage lightWizard = LightMage("Василий", 80, 50, 2000);
  lightWizard.defend();
  lightWizard.useLightPower();
}
Результат выполнения

Инкапсуляция

Инкапсуляцию можно представить как состав из двух слов в капсуле

Инкапсуляция как капсула

Можно упрощенно привести такой пример, что когда мы создали класс AbstractCharacter и добавили в него свойства и методы, то все эти характеристики мы инкапсулировали в пределах этого класса и поместили всё в защитную капсулу, как таблетку.

  1. Таким образом мы связали свойства и методы по смыслу, что они относятся к классу Персонаж
  2. Мы можешь управлять доступом к свойствам и методам. Где-то приоткрыть капсулу для доступа из вне, или наоборот полностью закрыться.
  3. Приоткрывать капсулу можно не для все характеристик сразу, а выборочно для определенных свойств или методов.
Управление доступом

Файл characters.dart

Dart - Публичные свойства

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int level;
  final int hp;

  AbstractCharacter(this.name, this.hp, this.level);

  void attack();
  void defend();

  @override
  String toString() {
    super.toString();
    return "Имя=$name, уровень=$level, здоровье=$hp";
  }
}

В данном случае данные инкапсулированны по смыслу с привязкой к классу данных.
Но все данные являются доступными из вне, т.е. публичными (public)
Хотя в данном случае и будет проблематично изменить значения свойств, потому что они являются final константами.
Но допустим, мы сделаем свойство hp обычной переменной и доступной.

Файл characters.dart

Dart - Изменяемое свойство hp

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int level;
  int hp;

  //...
}

В файле main.dart мы можем сделать что-то такое

Файл main.dart

Dart - Проблема с публичными данными

Светлая тема Темная тема
import 'characters.dart';

void main() {
  Warrior knight = Warrior("Воин", 100, 50, 20);

  knight.hp = 200; // Мы можем внаглую взять и изменить хп
  print(knight.hp); // Есть возможность посмотреть данные хп

  // А теперь можно сделать что-то читерское
  // Взять и обнулить жизни персонажу ... что не очень хорошо

  knight.hp = 0; // !!!
  print(knight.hp); // Упс ...
}

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

Файл characters.dart

Dart - Приватное свойство

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int level;
  int _hp; // Приватное свойство

  AbstractCharacter(this.name, this._hp, this.level);

  void attack();
  void defend();

  @override
  String toString() {
    super.toString();
    return "Имя=$name, уровень=$level, здоровье=$_hp";
  }
}

Файл main.dart

Dart - Ошибка доступа к приватному свойству

Светлая тема Темная тема
import 'characters.dart';

void main() {
  Warrior knight = Warrior("Воин", 100, 50, 20);

  knight.hp = 0; // ! Ошибка доступа. Теперь так делать нельзя!
  
  print(knight.hp); // Но и получить данные теперь тоже нельзя :(
}

Полный Private

Отлично! Мы полностью закрыли доступ к свойству hp!
Часто это очень нужно и важно скрыть внутреннюю реализацию класса, чтобы ничего не сломать!

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

Есть специальные механизмы для работы со свойствами класса

Файл characters.dart

Dart - Getter и Setter

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int level;
  int _hp;

  // геттер для доступа к закрытому полю
  int get hp => _hp;

  // сеттер для доступа к закрытому полю
  set hp(int value) {
    if (value <= 0) {
      print("Здоровье не может быть отрицательным!");
      print("И обнулять тоже читерство! :)");
      return;
    } else {
      _hp = value;
    }
  }

  AbstractCharacter(this.name, this._hp, this.level);

  void attack();
  void defend();

  @override
  String toString() {
    super.toString();
    return "Имя=$name, уровень=$level, здоровье=$_hp";
  }
}

Файл main.dart

Dart - Использование getter и setter

Светлая тема Темная тема
import 'characters.dart';

void main() {
  Warrior knight = Warrior("Воин", 100, 50, 20);

  knight.hp = 200;
  knight.hp = 0;

  print(knight.hp); 
}

// Здоровье не может быть отрицательным!
// И обнулять тоже читерство! :)
// 200

Полиморфизм

Полиморфизм можно разбить на два слова: "поли" — много, "морфизм" — форм.

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

Чтобы понять, зачем это нужно, давай начнём с примера. Вернёмся к нашим игровым персонажам. Мы создали базовый класс AbstractCharacter, но нужно, чтобы у разных классов-наследников (например, воинов, магов и лучников) были свои уникальные способности.

Код без полиморфизма — сплошной хаос!

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

Представим, что у нас есть два разных класса персонажей: Warrior и Mage. У каждого из них своя реализация метода attack(). Теперь мы хотим вызвать атаку для всех персонажей, но нам придётся учитывать их типы.

Файл characters.dart

Dart - Код без полиморфизма

Светлая тема Темная тема
class Warrior {
  final String name;
  final int strength;

  Warrior(this.name, this.strength);

  void attack() {
    print("$name атакует мечом с уроном $strength!");
  }
}

class Mage {
  final String name;
  final int mana;

  Mage(this.name, this.mana);

  void attack() {
    print("$name использует магию с силой $mana!");
  }
}

Файл main.dart

Dart - Проблема без полиморфизма

Светлая тема Темная тема
void main() {
  Warrior knight = Warrior("Рыцарь", 50); // Создаём рыцаря
  Mage wizard = Mage("Маг", 30); // Создаём мага
  String rouge = "Разбойник";

  // Представим, что мы собираем их в команду
  List team = [knight, wizard, rouge];

  // Попробуем у каждого вызывать метод attack()
  for (var character in team) {
    character.attack();
  }
}

И будет конечно же ошибка! Потому что к нам в команду попал, объект Разбойник у которого нет, метода attack()

Нужно теперь делать дополнительные проверки для каждого класса.

Файл main.dart

Dart - Решение без полиморфизма

Светлая тема Темная тема
void main() {
  Warrior knight = Warrior("Рыцарь", 50); // Создаём рыцаря
  Mage wizard = Mage("Маг", 30); // Создаём мага
  String rouge = "Разбойник";

  // Представим, что мы собираем их в команду
  List team = [knight, wizard, rouge];

  for (var character in team) {
    // Проверяем, кто именно находится в команде
    if (character is Warrior) {
      // Если рыцарь, вызываем attack() рыцаря
      character.attack(); 
    } else if (character is Mage) {
      // Если маг, вызываем attack() мага
      character.attack(); 
    } else {
      print("Неизвестный персонаж в команде!");
    }
  }
}

Результат:

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

Рыцарь атакует мечом с уроном 50!
Маг использует магию с силой 30!
Неизвестный персонаж в команде!

Это простой пример, где можно сразу заметить ошибку до компиляции кода, но в реальном приложении, это будет уже проблематично!
У нас не будет гарантии, например, что все объекты которые мы обрабатываем будут иметь, вызываемые методы!

Эту проблему в данном случае, решает абстрактный класс, который предоставляет одинаковый интерфейс для взаимодействия между всеми объектами.

Повторим код теперь с применением полиморфизма

Мы определяем базовый класс с методом attack()

Файл characters.dart

Dart - Абстрактный класс для полиморфизма

Светлая тема Темная тема
abstract class AbstractCharacter {
  final String name;
  final int level;
  int _hp;

  AbstractCharacter(this.name, this._hp, this.level);

  void attack(); // Абстрактный метод — каждая реализация своя!
}

А теперь у каждого потомка этого класса будет своя реализация метода attack()

Файл characters.dart

Dart - Полиморфные классы

Светлая тема Темная тема
class Warrior extends AbstractCharacter {
  final int strength;

  Warrior(super.name, super.hp, super.level, this.strength);

  @override // 👈👈👈 переопрделяем метод
  void attack() {
    print("$name атакует мечом! Урон: ${strength * level}");
  }
}

class Mage extends AbstractCharacter {
  final int mana;

  Mage(super.name, super.hp, super.level, this.mana);

  @override // 👈👈👈 переопрделяем метод
  void attack() {
    print("$name использует заклинание! Магическая сила: ${mana * level}");
  }
}

Теперь всё гораздо проще. Мы можем собрать всех персонажей в общий список и использовать один вызов attack() для всех:

Файл main.dart

Dart - Полиморфизм в действии

Светлая тема Темная тема
void main() {
  List<AbstractCharacter> team = [
    Warrior("Рыцарь", 100, 50, 20),
    Mage("Маг", 80, 30, 50),
    Warrior("Паладин", 120, 70, 25),
  ];

  // Полиморфизм в действии:
  for (AbstractCharacter character in team) {
    character.attack(); // Вызовется своя реализация у каждого!
  }
}

Гарантия и Гибкость работы

Указание типа AbstractCharacter гарантирует, что будут использоваться только те объекты у которых есть нужный метод attack()

Если мы добавим нового наследника (например, лучника), он автоматически подхватится:

Файл characters.dart

Dart - Новый класс Archer

Светлая тема Темная тема
class Archer extends AbstractCharacter {
  final int agility;

  Archer(super.name, super.hp, super.level, this.agility);

  @override
  void attack() {
    print("$name стреляет из лука с точностью $agility!");
  }
}

Просто добавим его в список team, и всё будет работать:

Файл main.dart

Dart - Расширение команды

Светлая тема Темная тема
void main() {
  List<AbstractCharacter> team = [
    Warrior("Рыцарь", 100, 50, 20),
    Mage("Маг", 80, 30, 50),
    Warrior("Паладин", 120, 70, 25),
  ];

  Archer ranger = Archer("Лучник", 40, 100, 100); 
  team.add(ranger);

  // Полиморфизм в действии:
  for (AbstractCharacter character in team) {
    character.attack(); // Вызовется своя реализация у каждого!
  }
}

Результат:

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

Рыцарь атакует мечом! Урон: 1000
Маг использует заклинание! Магическая сила: 1500
Паладин атакует мечом! Урон: 1750
Лучник стреляет из лука с точностью 100!