Как писать красивые модели: обзор принципов проектирования в объектно-ориентированном программировании

искусственный интеллект Python Язык программирования игра

Выбрано из Medium, собрано сердцем машины.

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

Объектно-ориентированное программирование (ООП) поддерживается и поощряется большинством современных языков программирования. Несмотря на то, что в последнее время мы наблюдаем некоторое расхождение, поскольку люди начинают использовать языки программирования, на которые меньше влияет ООП (например, Go, Rust, Elixir, Elm, Scala), большинство из них имеют объектно-ориентированные свойства. Принципы проектирования, которые мы изложили здесь, также применимы к языкам программирования, отличным от ООП.

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

тип объекта

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

1. Сущностные объекты

Такие объекты обычно соответствуют некоторым реальным объектам в проблемном пространстве. Например, если мы хотим создать ролевую игру (RPG), простой класс Hero является объектом-сущностью.

class Hero:
    def __init__(self, health, mana):
        self._health = health
        self._mana = mana

    def attack(self) -> int:
        """
        Returns the attack damage of the Hero
        """
        return 1

    def take_damage(self, damage: int):
        self._health -= damage

    def is_alive(self):
        return self._health > 0

Такие объекты обычно содержат свойства о самих себе (например, здоровье или мана), которые можно изменить в соответствии с определенными правилами.

2. Объект управления

Объекты управления (иногда называемые объектами управления) в первую очередь отвечают за координацию с другими объектами, которые управляют другими объектами и вызывают их. Отличный пример из нашего случая RPG выше, класс Fight управляет двумя героями и настраивает их друг против друга.

class Fight:
    class FightOver(Exception):
        def __init__(self, winner, *args, **kwargs):
            self.winner = winner
            super(*args, **kwargs)

    def __init__(self, hero_a: Hero, hero_b: Hero):
        self._hero_a = hero_a
        self._hero_b = hero_b
        self.fight_ongoing = True
        self.winner = None

    def fight(self):
        while self.fight_ongoing:
            self._run_round()
        print(f'The fight has ended! Winner is #{self.winner}')

    def _run_round(self):
        try:
            self._run_attack(self._hero_a, self._hero_b)
            self._run_attack(self._hero_b, self._hero_a)
        except self.FightOver as e:
            self._finish_round(e.winner)

    def _run_attack(self, attacker: Hero, victim: Hero):
        damage = attacker.attack()
        victim.take_damage(damage)
        if not victim.is_alive():
            raise self.FightOver(winner=attacker)

    def _finish_round(self, winner: Hero):
        self.winner = winner
        self.fight_ongoing = False

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

3. Граничный объект

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

class UserInput:
    def __init__(self, input_parser):
        self.input_parser = input_parser

    def take_command(self):
        """
        Takes the user's input, parses it into a recognizable command and returns it
        """
        command = self._parse_input(self._take_input())
        return command

    def _parse_input(self, input):
        return self.input_parser.parse(input)

    def _take_input(self):
        raise NotImplementedError()

class UserMouseInput(UserInput):
    pass

class UserKeyboardInput(UserInput):
    pass

class UserJoystickInput(UserInput):
    pass

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

Бонус: Ценный объект

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

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

class Money:
    def __init__(self, gold, silver, copper):
        self.gold = gold
        self.silver = silver
        self.copper = copper

    def __eq__(self, other):
        return self.gold == other.gold and self.silver == other.silver and self.copper == other.copper

    def __gt__(self, other):
        if self.gold == other.gold and self.silver == other.silver:
            return self.copper > other.copper
        if self.gold == other.gold:
            return self.silver > other.silver

        return self.gold > other.gold

    def __add__(self, other):
        return Money(gold=self.gold + other.gold, silver=self.silver + other.silver, copper=self.copper + other.copper)

    def __str__(self):
        return f'Money Object(Gold: {self.gold}; Silver: {self.silver}; Copper: {self.copper})'

    def __repr__(self):
        return self.__str__()


print(Money(1, 1, 1) == Money(1, 1, 1))
# => True
print(Money(1, 1, 1) > Money(1, 2, 1))
# => False
print(Money(1, 1, 0) + Money(1, 1, 1))
# => Money Object(Gold: 2; Silver: 2; Copper: 1)

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

ключевые принципы дизайна

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

Абстракция

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

Приведенный выше пример игры иллюстрирует абстракцию, давайте посмотрим, как устроен класс Fight. Мы используем его самым простым способом, передав ему двух героев в качестве аргументов во время создания экземпляра, а затем вызвав метод fight(). Не больше, не меньше, вот и все.

Абстракция в коде должна следовать принципу наименьших неожиданностей (POLA), абстракция не должна использовать ненужные и нерелевантные поведения/свойства. Другими словами, он должен быть интуитивно понятным.

Обратите внимание, что наша функция Hero#take_damage() не делает необычных вещей, таких как удаление живого персонажа. Но если его здоровье упадет ниже нуля, мы можем ожидать, что это убьет нашего персонажа.

упаковка

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

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

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

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

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

авария

Декомпозиция — это разделение объекта на более мелкие независимые части, которые легче понять, поддерживать и программировать.

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

class Hero:
    def __init__(self, health, mana):
        self._health = health
        self._mana = mana
        self._strength = 0
        self._agility = 0
        self._stamina = 0
        self.level = 0
        self._items = {}
        self._equipment = {}
        self._item_capacity = 30
        self.stamina_buff = None
        self.agility_buff = None
        self.strength_buff = None
        self.buff_duration = -1

    def level_up(self):
        self.level += 1
        self._stamina += 1
        self._agility += 1
        self._strength += 1
        self._health += 5

    def take_buff(self, stamina_increase, strength_increase, agility_increase):
        self.stamina_buff = stamina_increase
        self.agility_buff = agility_increase
        self.strength_buff = strength_increase
        self._stamina += stamina_increase
        self._strength += strength_increase
        self._agility += agility_increase
        self.buff_duration = 10  # rounds

    def pass_round(self):
        if self.buff_duration > 0:
            self.buff_duration -= 1
        if self.buff_duration == 0:  # Remove buff
            self._stamina -= self.stamina_buff
            self._strength -= self.strength_buff
            self._agility -= self.agility_buff
            self._health -= self.stamina_buff * 5
            self.buff_duration = -1
            self.stamina_buff = None
            self.agility_buff = None
            self.strength_buff = None

    def attack(self) -> int:
        """
        Returns the attack damage of the Hero
        """
        return 1 + (self._agility * 0.2) + (self._strength * 0.2)

    def take_damage(self, damage: int):
        self._health -= damage

    def is_alive(self):
        return self._health > 0

    def take_item(self, item: Item):
        if self._item_capacity == 0:
            raise Exception('No more free slots')
        self._items[item.id] = item
        self._item_capacity -= 1

    def equip_item(self, item: Item):
        if item.id not in self._items:
            raise Exception('Item is not present in inventory!')
        self._equipment[item.slot] = item
        self._agility += item.agility
        self._stamina += item.stamina
        self._strength += item.strength
        self._health += item.stamina * 5
# 缺乏分解的案例

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

Например, наш показатель выносливости равен 5 единицам здоровья, и если в будущем он будет изменен на 6 единиц здоровья, нам придется изменить эту реализацию в ряде мест.

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

from copy import deepcopy

class AttributeCalculator:
    @staticmethod
    def stamina_to_health(self, stamina):
        return stamina * 6

    @staticmethod
    def agility_to_damage(self, agility):
        return agility * 0.2

    @staticmethod
    def strength_to_damage(self, strength):
        return strength * 0.2

class HeroInventory:
    class FullInventoryException(Exception):
        pass

    def __init__(self, capacity):
        self._equipment = {}
        self._item_capacity = capacity

    def store_item(self, item: Item):
        if self._item_capacity < 0:
            raise self.FullInventoryException()
        self._equipment[item.id] = item
        self._item_capacity -= 1

    def has_item(self, item):
        return item.id in self._equipment

class HeroAttributes:
    def __init__(self, health, mana):
        self.health = health
        self.mana = mana
        self.stamina = 0
        self.strength = 0
        self.agility = 0
        self.damage = 1

    def increase(self, stamina=0, agility=0, strength=0):
        self.stamina += stamina
        self.health += AttributeCalculator.stamina_to_health(stamina)
        self.damage += AttributeCalculator.strength_to_damage(strength) + AttributeCalculator.agility_to_damage(agility)
        self.agility += agility
        self.strength += strength

    def decrease(self, stamina=0, agility=0, strength=0):
        self.stamina -= stamina
        self.health -= AttributeCalculator.stamina_to_health(stamina)
        self.damage -= AttributeCalculator.strength_to_damage(strength) + AttributeCalculator.agility_to_damage(agility)
        self.agility -= agility
        self.strength -= strength

class HeroEquipment:
    def __init__(self, hero_attributes: HeroAttributes):
        self.hero_attributes = hero_attributes
        self._equipment = {}

    def equip_item(self, item):
        self._equipment[item.slot] = item
        self.hero_attributes.increase(stamina=item.stamina, strength=item.strength, agility=item.agility)


class HeroBuff:
    class Expired(Exception):
        pass

    def __init__(self, stamina, strength, agility, round_duration):
        self.attributes = None
        self.stamina = stamina
        self.strength = strength
        self.agility = agility
        self.duration = round_duration

    def with_attributes(self, hero_attributes: HeroAttributes):
        buff = deepcopy(self)
        buff.attributes = hero_attributes
        return buff

    def apply(self):
        if self.attributes is None:
            raise Exception()
        self.attributes.increase(stamina=self.stamina, strength=self.strength, agility=self.agility)

    def deapply(self):
        self.attributes.decrease(stamina=self.stamina, strength=self.strength, agility=self.agility)

    def pass_round(self):
        self.duration -= 0
        if self.has_expired():
            self.deapply()
            raise self.Expired()

    def has_expired(self):
        return self.duration == 0


class Hero:
    def __init__(self, health, mana):
        self.attributes = HeroAttributes(health, mana)
        self.level = 0
        self.inventory = HeroInventory(capacity=30)
        self.equipment = HeroEquipment(self.attributes)
        self.buff = None

    def level_up(self):
        self.level += 1
        self.attributes.increase(1, 1, 1)

    def attack(self) -> int:
        """
        Returns the attack damage of the Hero
        """
        return self.attributes.damage

    def take_damage(self, damage: int):
        self.attributes.health -= damage

    def take_buff(self, buff: HeroBuff):
        self.buff = buff.with_attributes(self.attributes)
        self.buff.apply()

    def pass_round(self):
        if self.buff:
            try:
                self.buff.pass_round()
            except HeroBuff.Expired:
                self.buff = None

    def is_alive(self):
        return self.attributes.health > 0

    def take_item(self, item: Item):
        self.inventory.store_item(item)

    def equip_item(self, item: Item):
        if not self.inventory.has_item(item):
            raise Exception('Item is not present in inventory!')
        self.equipment.equip_item(item)

Теперь, после разделения объекта Hero на объекты HeroAttributes, HeroInventory, HeroEquipment и HeroBuff, будущие дополнения стали проще, более инкапсулированы и с лучшими абстракциями, а код становится чище.

Ниже приведены три соотношения декомпозиции:

  • Ассоциация: Определяет непринужденную связь между двумя компонентами. Два компонента не зависят друг от друга, но могут работать вместе. Например, объекты-герои и объекты-зоны.
  • Агрегация: определяет слабую связь «содержит» между целым и частями. Эта связь слабее, потому что части могут существовать без целого. Например HeroInventory и Item. В HeroInventory может быть много Предметов, и Предмет может принадлежать любому HeroInventory (например, транзакционный предмет).
  • Композиция: сильная связь «содержит», в которой целое и части не могут быть отделены друг от друга. Части не могут быть разделены, потому что целое зависит от этих конкретных частей. Например, Hero и HeroAttributes.

обобщение

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

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


# Two methods which share common characteristics
def take_physical_damage(self, physical_damage):
    print(f'Took {physical_damage} physical damage')
    self._health -= physical_damage

def take_spell_damage(self, spell_damage):
    print(f'Took {spell_damage} spell damage')
    self._health -= spell_damage

# vs.

# One generalized method
def take_damage(self, damage, is_physical=True):
    damage_type = 'physical' if is_physical else 'spell'
    print(f'Took {damage} {damage_type} damage')
    self._health -= damage
    

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

class Entity:
    def __init__(self):
        raise Exception('Should not be initialized directly!')

    def attack(self) -> int:
        """
        Returns the attack damage of the Hero
        """
        return self.attributes.damage

    def take_damage(self, damage: int):
        self.attributes.health -= damage

    def is_alive(self):
        return self.attributes.health > 0


class Hero(Entity):
    pass

class NPC(Entity):
    pass

В данном примере мы обобщаем обычно используемые класс Hero и класс NPC в общий родительский класс Entity и упрощаем создание подклассов посредством наследования.

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

Мы можем злоупотреблять наследованием, поэтому многие опытные люди рекомендуют предпочитать композицию наследованию (stackoverflow.com/a/53354).

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

комбинация

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

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

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

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

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

class Entity:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        raise Exception('Should not be initialized directly!')

    def attack(self) -> int:
        """
        Returns the attack damage of the Hero
        """
        return self.attributes.damage

    def take_damage(self, damage: int):
        self.attributes.health -= damage

    def is_alive(self):
        return self.attributes.health > 0

    def move_left(self):
        self.x -= 1

    def move_right(self):
        self.x += 1


class Hero(Entity):
    pass

class NPC(Entity):
    pass

Как мы узнали, вместо прямого дублирования кода мы перенесли функции move_right и move_left в класс Entity.
Ну а если мы хотим ввести в игру маунтов? Маунт также должен двигаться влево и вправо, но у него нет ни способности атаковать, ни даже здоровья.

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

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

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

критическое мышление

Хотя эти принципы проектирования формируются на протяжении десятилетий, важно критически подумать, прежде чем слепо применять эти принципы к коду.

Что-то слишком! Иногда эти принципы могут иметь большое значение, но на практике иногда превращаются во что-то, что трудно использовать.

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

Сплоченность, связь и разделение интересов

Сплоченность

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

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

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

связь

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

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

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

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

Хорошим примером является веб-страница, которая имеет три уровня (информационный уровень, уровень представления и уровень поведения), которые разделены на три разных места (HTML, CSS и JS соответственно).

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

Эпилог

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

Эти принципы гарантируют, что наша система больше:

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

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

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

Оригинальный адрес:medium.free код camp.org/ah-short-o ve…