ООП Часть 1 в Dart

Объектно-Ориентированное Программирование

ООП схема

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

Основные характеристики процедурного подхода:

Характеристики процедурного подхода

Рассмотрим пример программы в процедурном стиле и выделим её ограничения и проблемы

Dart - Процедурный подход

Светлая тема Темная тема
void main() {
  String playerName = "Геральт из Ривии";
  int playerHp = 100;
  int playerAttack = 20;
  int playerDefence = 10;

  String monsterName = "Леший из Лихолесья";
  int monsterHp = 50;
  int monsterAttack = 12;
  int monsterDefence = 8;

  /// Функция, с помощью которой игрок наносит урон монстру.
  void playerDealsDamage() {
    int damage = playerAttack - monsterDefence;
    if (damage < 0) damage = 0;
    monsterHp -= damage;

    if (monsterHp < 0) {
      print("Монстр проиграл");
      return;
    }

    print("Игрок наносит монстру $damage урона");
    print("У монстра осталось $monsterHp HP");
  }

  /// Функция, с помощью которой монстр наносит урон игроку.
  void monsterDealsDamage() {
    int damage = monsterAttack - playerDefence;
    if (damage < 0) damage = 0;
    playerHp -= damage;
    
    print("Монстр наносит игроку $damage урона");
    print("У игрока осталось $playerHp HP");
  }

  /// Функция повышения уровня игрока.
  void levelUp() {
    print("Поздравляем! Уровень повышен! Ваши статы усилены!");
    playerHp += 10;
    playerAttack += 5;
    playerDefence += 2;
  }

  /// Функция для отображения текущих характеристик игрока.
  showPlayerStats() {
    print("Имя игрока: $playerName");
    print("Здоровье игрока: $playerHp");
    print("Атака игрока: $playerAttack");
    print("Защита игрока: $playerDefence");
  }

  /// Функция для отображения текущих характеристик монстра.
  showMonsterStats() {
    print("Имя монстра: $monsterName");
    print("Здоровье монстра: $monsterHp");
    print("Атака монстра: $monsterAttack");
    print("Защита монстра: $monsterDefence");
  }

  // Выпускаем кракена — последовательность ходов
  playerDealsDamage();
  playerDealsDamage();
  monsterDealsDamage();
  playerDealsDamage();
  playerDealsDamage();
  playerDealsDamage();

  levelUp();
  showPlayerStats();
}

Проблемы процедурного подхода

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

Проблемы с добавлением монстров

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

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

Схема проблем с переменными

Dart - Проблема с множественными монстрами

Светлая тема Темная тема
void main() {
  String playerName = "Геральт из Ривии";
  int playerHp = 100;
  int playerAttack = 20;
  int playerDefence = 10;

  String monsterName = "Леший из Лихолесья";
  int monsterHp = 50;
  int monsterAttack = 12;
  int monsterDefence = 8;

  // 👉👉👉 Ещё пачка переменных 😨
  String monster2Name = "Злобный Бобр";
  int monster2Hp = 7000;
  int monster2Attack = 20;
  int monster2Defence = 0;

  void playerDealsDamage() {
    // ...
  }

  // 👉👉👉 Придётся дополнительно создать ещё функцию для битвы с новым монстром
  void playerDealsDamageToMonster2() {
    int damage = playerAttack - monster2Defence;
    if (damage < 0) damage = 0;
    monster2Hp -= damage;

    if (monster2Hp < 0) {
      print("Монстр проиграл");
      return;
    }

    print("Игрок наносит монстру $damage урона. У монстра осталось $monster2Hp HP.");
  }

  void monsterDealsDamage() {
    // ...
  }

  // 👉👉👉 Так же придётся делать и функцию атаки для нового монстра
  void monster2DealsDamage() {
    int damage = monster2Attack - playerDefence;
    if (damage < 0) damage = 0;
    playerHp -= damage;
    print("Монстр наносит игроку $damage урона. У игрока осталось $playerHp HP.");
  }

  void levelUp() {
    //...
  }
  showPlayerStats() {
    //...
  }
  showMonsterStats() {
    //...
  }

  // Выпускаем кракена
  playerDealsDamage();
  playerDealsDamage();
  monsterDealsDamage();
  playerDealsDamage();
  playerDealsDamage();
  playerDealsDamage();

  levelUp();
  showPlayerStats();
}
Хаос в коде

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

Хаотичный код

Как справиться с подобной ситуацией? Решение заключается в использовании другой парадигмы программирования, известной как Объектно-Ориентированное Программирование (ООП). Этот подход помогает структурировать код, связывая данные и функциональность в единые логические блоки.

Введение в ООП

Как справиться с подобной ситуацией? Решение заключается в использовании другой парадигмы программирования, известной как Объектно-Ориентированное Программирование (ООП). Этот подход помогает структурировать код, связывая данные и функциональность в единые логические блоки.

Давайте сразу преобразуем наш пример в стиле ООП

Dart - Первый класс

Светлая тема Темная тема
class Character {
  // Свойства класса
  String name = "";
  int hp = 1;
  int attack = 1;
  int defence = 1;

  // Конструктор
  Character(String name, int hp, int attack, int defence) {
    this.name = name;
    this.hp = hp;
    this.attack = attack;
    this.defence = defence;
  }
}

void main() {

}
Объяснение класса 1 Объяснение класса 2 Объяснение класса 3 Объяснение класса 4 Объяснение класса 5 Объяснение класса 6 Объяснение класса 7

1. Создание класса
Первым шагом мы должны создать класс, который станет основой для создания объектов. Класс можно рассматривать как шаблон или трафарет, который определяет общие свойства и действия для всех объектов одного типа.

2. Назовём класс Character
Этот будет абстрактный персонаж. Классы всегда называются с заглавной буквы. Класс создаётся за пределами функции main(), чтобы быть доступным везде в программе.

3. Добавление свойств и методов
Внутри фигурных скобок { } класса мы опишем свойства и методы будущих объектов. Свойства характеризуют объект (например, имя, количество жизней), а методы определяют его действия (например, атака, защита).

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

У любого объекта есть 2 важные характеристики (например для человека):
Свойства - возраст, рост, цвет глаз, вес, ...
Действия - учиться, спать, работать, играть в зельду ...

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

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

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

7. Ключевое слово this
Внутри конструктора используется ключевое слово this, которое ссылается на текущий объект. Это необходимо, чтобы чётко указать, что мы обращаемся к свойствам данного объекта, а не к переменным или значениям из внешней области видимости. Проще говоря, this можно перевести как "этот" или "текущий" объект. Обращение к самому себе.

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

Создание и использование объектов

Dart - Создание объекта

Светлая тема Темная тема
void main() {
  // Создание экземпляра класса (объекта)
  Character character = Character("", 1, 1, 1);

  character.hp = 100; // Измнение свойства
  print(character.hp); // Вывод свойства
}

Для создания нового экземпляра класса (новый объект), мы сначала указываем тип данных (имя класса), затем задаём имя объекта, а после этого вызываем конструктор класса, чтобы инициализировать свойства нового объекта.

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

Точечный синтаксис

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

Dart - Сокращённый конструктор

Светлая тема Темная тема
class Character {
  // Свойства класса
  String name;
  int hp;
  int attack;
  int defence;

  // Конструктор
  Character(this.name, this.hp, this.attack, this.defence);
}

Множественные объекты из одного класса

Сокращённый синтаксис

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

Множественные объекты

Класс выступает в роли шаблона, который определяет общую структуру (свойства и методы), а каждый объект, созданный на его основе, может иметь собственные данные и поведение.

Dart - Создание множественных объектов

Светлая тема Темная тема
void main() {
  // Создание экземпляра класса (объекта)
  Character player = Character("Геральт из Ривии", 100, 20, 10);
  Character monster1 = Character("Леший из Лихолесья", 50, 12, 8);
  Character monster2 = Character("Злобный Бобр", 2000, 50, 1);

  print(player.name); // "Геральт из Ривии"
  print(monster1.name); // "Леший из Лихолесья"
  print(monster2.name); // "Злобный Бобр"
}

Мы создали три разных объекта, используя единственный шаблон класса Character

Это наглядно демонстрирует удобство и гибкость объектно-ориентированного программирования.

Уникальные свойства объектов

Каждый объект — player, monster1, monster2 — обладает одинаковым набором свойств, например, name. Однако самое важное — у каждого из этих объектов значение свойства name уникально. Это позволяет хранить уникальную информацию для каждого объекта и работать с ними независимо.

Уникальные свойства

Связь с this

Когда мы вызываем конструктор класса для создания нового объекта, ключевое слово this внутри класса автоматически связывается с этим объектом.

Проще говоря, this обозначает текущий объект, для которого выполняется код.

Если в конструкторе класса написано:

this.name = name;

И создаётся объект player, то это выражение становится:

player.name = name;

А если создаётся объект monster1, то оно трансформируется в:

monster1.name = name;

Таким образом, this автоматически заменяется текущим объектом, что делает создание и управление объектами простым и логичным.

Добавление методов к классу

Добавим методы для вывода информации о персонаже

Создадим метод displayInfo, который будет выводить основные характеристики объекта, такие как имя, здоровье, атака и защита.

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

Свойство объекта – вызывается через точку и без скобок
Метод объекта – вызывается через точку и со скобками

Точечный синтаксис для методов

Методы позволяют делать объекты более «живыми» и взаимодействующими с данными.
Это один из ключевых аспектов работы в стиле ООП.

Dart - Класс с методами

Светлая тема Темная тема
class Character {
  // Свойства
  String name;
  int hp;
  int attack;
  int defence;

  // Конструктор
  Character(this.name, this.hp, this.attack, this.defence);

  // Методы
  void showStats() {
    print("Имя: $name");
    print("Здоровье: $hp");
    print("Атака: $attack");
    print("Защита: $defence");
  }
}

void main() {
  // Создание экземпляра класса (объекта)
  Character player = Character("Геральт из Ривии", 100, 20, 10);
  Character monster1 = Character("Леший из Лихолесья", 50, 12, 8);
  Character monster2 = Character("Злобный Бобр", 2000, 50, 1);

  player.showStats();
  monster1.showStats();
  monster2.showStats();
}

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

Преимущества, которые мы получили:

  1. Связанность данных и функций: Все свойства и методы, относящиеся к одному объекту, находятся внутри его класса, а не разбросаны по коду.

  2. Изоляция данных: Мы чётко определяем, к какому объекту относятся те или иные свойства и методы. Теперь невозможно случайно «перепутать» данные одного персонажа с данными другого.

  3. Легкость масштабирования: Для создания нового объекта достаточно использовать класс, без необходимости копирования кода.

Полный пример с боевой системой

Добавим остальные методы из процедурного примера

Dart - Полный класс Character

Светлая тема Темная тема
class Character {
  // Свойства
  String name;
  int hp;
  int attack;
  int defence;

  // Конструктор
  Character(this.name, this.hp, this.attack, this.defence);

  // Методы
  void showStats() {
    print("Имя: $name");
    print("Здоровье: $hp");
    print("Атака: $attack");
    print("Защита: $defence");
  }

  void dealDamage(Character target) {
    int damage = attack - target.defence;
    if (damage < 0) damage = 0;
    target.hp -= damage;

    if (target.hp <= 0) {
      print("${target.name} проиграл");
    } else {
      print("$name наносит ${target.name} $damage урона. У ${target.name} осталось ${target.hp} HP.");
    }
  }

  void levelUp() {
    print("Поздравляем, $name! Уровень повышен! Ваши статы усилены!");
    hp += 10;
    attack += 5;
    defence += 2;
  }
}

void main() {
  // Создание экземпляра класса (объекта)
  Character player = Character("Геральт из Ривии", 100, 20, 10);
  Character monster1 = Character("Леший из Лихолесья", 50, 12, 8);
  Character monster2 = Character("Злобный Бобр", 2000, 50, 1);

  // Выпускаем кракена
  player.dealDamage(monster1);
  player.dealDamage(monster1);
  monster1.dealDamage(player);
  player.dealDamage(monster1);
  player.dealDamage(monster1);
  player.dealDamage(monster1);

  player.levelUp();
  player.showStats();
}

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

Геральт из Ривии наносит Леший из Лихолесья 12 урона. 
У Леший из Лихолесья осталось 38 HP.

Геральт из Ривии наносит Леший из Лихолесья 12 урона. 
У Леший из Лихолесья осталось 26 HP.

Леший из Лихолесья наносит Геральт из Ривии 2 урона. 
У Геральт из Ривии осталось 98 HP.

Геральт из Ривии наносит Леший из Лихолесья 12 урона. 
У Леший из Лихолесья осталось 14 HP.

Геральт из Ривии наносит Леший из Лихолесья 12 урона. 
У Леший из Лихолесья осталось 2 HP.

Леший из Лихолесья проиграл

Поздравляем, Геральт из Ривии! Уровень повышен! Ваши статы усилены!

Имя: Геральт из Ривии
Здоровье: 108
Атака: 25
Защита: 12