How to Write Beautiful Models: An Overview of Design Principles in Object-Oriented Programming

Selected from Medium, compiled by the heart of the machine.

Object-oriented programming is very important in the process of implementing ideas and even systems. Whether we use TensorFlow or PyTorch to build models, we need to use classes and methods more or less. The use of classes to build models will make the code very readable and organized. This article introduces the design principles that need to be paid attention to when using classes and methods to build models in algorithm implementation. They can make our machine learning code more beautiful. charming.

Object-oriented programming (OOP) is supported and encouraged by most modern programming languages. Even though we seem to be seeing some divergence recently as people start using programming languages ​​that are less influenced by OOP (e.g. Go, Rust, Elixir, Elm, Scala), most have object-oriented properties. The design principles we outline here also apply to non-OOP programming languages.

To successfully write clean, high-quality, maintainable, and extensible code, we need to use Python as an example to understand design principles that have proven effective over the past few decades.

object type

Because we're building code around objects, it's useful to distinguish between their different responsibilities and variations. Generally speaking, there are three types of objects in object-oriented programming.

1. Entity objects

Such objects usually correspond to some real entities in the problem space. For example, if we want to build a role-playing game (RPG), the simple Hero class is an entity object.

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

Such objects usually contain properties about themselves (such as health or mana) that are modifiable according to specific rules.

2. Control Object

Control objects (sometimes called management objects) are primarily responsible for coordinating with other objects, which are objects that manage and invoke other objects. A great example from our RPG case above, the Fight class controls two heroes and pits them against each other.

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

In such a class, encapsulating programming logic for matches can give us several benefits: one of them is the extensibility of actions. We can easily pass heroes involved in combat to non-player characters (NPCs) so they can utilize the same API. We can also easily inherit this class and override some functions to meet new needs.

3. Boundary Object

These are objects at the edge of the system. Any object that takes input from or produces output to another system can be classified as a boundary object, whether that system is a user, the Internet, or a database.

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

These boundary objects are responsible for passing information to and from the system. For example, to receive user commands, we need a bounds object to translate keyboard input (such as a space bar) into a recognizable domain event (such as a character's jump).

Bonus: Value Object

A value object represents a simple value in a domain. They cannot be changed, they are not constant.

If we combine them in our game, the Money class or the Damage class would represent such an object. The above objects allow us to easily distinguish, find and debug related functions that are not possible with just basic integer arrays or integers.

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)

They can be classified as subcategories of entity objects.

key design principles

Design principles are rules in software design that have proven valuable over the years. Strict adherence to these principles helps the software achieve top-notch quality.

Abstraction

Abstraction is a kind of thought that reduces a concept to its original essence in a certain context. It allows us to disassemble a concept to better understand it.

The above game example illustrates the abstraction, let's see how the Fight class is constructed. We use it in the simplest possible way, by giving it two heroes as parameters during instantiation, and then calling the fight() method. No more, no less, that's all.

Abstraction in code should follow the principle of least surprises (POLA), abstraction should not use unnecessary and irrelevant behaviors/properties. In other words, it should be intuitive.

Note that our Hero#take_damage() function doesn't do unusual things, such as removing a character while still alive. But if his health drops below zero, we can expect it to kill our character.

package

Encapsulation can be thought of as putting something inside a class and limiting the information it exposes to the outside world. In software, restricting access to internal objects and properties helps ensure data integrity.

By encapsulating the internal programming logic as a black box, our classes will be easier to manage because we know what parts can be used by other systems and what can't. This means that we can reuse internal logic while keeping the common parts and guaranteeing that nothing is broken. Also, it's simpler for us to use the encapsulated functionality from the outside because there are fewer things to think about.

In most programming languages, encapsulation is done through so-called Access modifiers (eg private, protected, etc.). Python isn't the best example of this because it can't build such explicit modifiers at runtime, but we use conventions to get around that. The _ prefix in front of variables and functions means they are private.

For example, imagine modifying our Fight#_run_attack method to return a boolean variable, which means the fight is over instead of an accident. We'll know that the only code we could potentially break is inside the Fight class, since we made this function private.

Remember that code is more about being modified than rewritten. Being able to modify code in the clearest and least impactful way possible is important for development flexibility.

break down

Decomposition is the splitting of an object into smaller independent parts that are easier to understand, maintain, and program.

Imagine we now want the Hero class to incorporate more RPG features such as buffs, assets, gear, character attributes.

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
# 缺乏分解的案例

We might say that this code has started to get quite messy. Our Hero object had too many properties set at once, and as a result this code became rather brittle.

For example, our stamina score is 5 health, and if it were to be changed to 6 health in the future, we would have to modify this implementation in a number of places.

The solution is to decompose the Hero object into several smaller objects, each of which can take on some functionality. The following shows a logically clear architecture:

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)

Now, after breaking the Hero object into HeroAttributes, HeroInventory, HeroEquipment, and HeroBuff objects, future additions are easier, more encapsulated, and with better abstractions, and the code is getting cleaner.

The following are three decomposition relationships:

  • Association: Defines a relaxed relationship between two components. The two components do not depend on each other, but can work together. For example Hero objects and Zone objects.
  • Aggregation: Defines a weak "contains" relationship between the whole and the parts. This relationship is weaker because the parts can exist without the whole. For example HeroInventory (hero property) and Item (entry). A HeroInventory can have many Items, and an Item can belong to any HeroInventory (eg, a transaction item).
  • Composition: A strong "contains" relationship in which the whole and the parts cannot be separated from each other. Parts cannot be shared because the whole depends on those specific parts. For example Hero and HeroAttributes.

generalization

Generalization is probably the most important design principle, the process by which we extract shared features and combine them together. We all know the inheritance of functions and classes, which is a kind of generalization.

A comparison might make this clearer: while abstraction reduces complexity by hiding unnecessary details, generalization works by substituting a single construct for multiple entities that perform similar functions.


# 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
    

The above is an example of a function that lacks generalization performance, while the case with generalization performance is shown below.

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

In the given example, we generalize the commonly used Hero class and NPC class into a common parent class Entity, and simplify the construction of subclasses through inheritance.

Here, instead of having the NPC and Hero classes implement everything twice, we reduce complexity by moving their common functionality into the base class.

We can overuse inheritance, so many experienced people suggest that we prefer composition over inheritance ( stackoverflow.com/a/53354 ).

Inheritance is often abused by inexperienced programmers, probably because inheritance is their first OOP technique.

combination

Composition is the process of combining multiple objects into a more complex object. This method creates instances of objects and uses their functionality instead of directly inheriting from it.

An object that uses the principle of composition is called a composite object. It is very important that this composite object is simpler than all its components. When combining multiple classes into a single class, we want to raise the level of abstraction and make objects simpler.

The API of a composite object must hide its internal modules, and the interactions between the internal modules. Like a mechanical clock, it has three hands to show the time, and a knob to set the time, but it contains a lot of moving individual parts inside.

As I said, composition is better than inheritance, which means that we should strive to move common functionality into a separate object, and then other classes use that object's functionality, rather than hiding it in the inherited base class middle.

Let's address a possible problem with overuse of inheritance, for now we're just adding an action to the game:

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

As we have learned, instead of duplicating the code directly, we moved move_right and move_left into the Entity class.
Well, what if we want to introduce mounts into the game? The mount should also need to move left and right, but it doesn't have the ability to attack, or even health.

Our solution might be to simply move the move logic into a separate MoveableEntity or MoveableObject class that just contains that functionality.

So what if we want the mount to have health, but not be able to attack? Hopefully you can see how the class hierarchy can become complex, even though our business logic is still fairly simple.

A somewhat better approach is to abstract the action logic into the Movement class (or better name) and instantiate it in classes that might be needed. This will nicely encapsulate functions and make them reusable across all kinds of objects, not just entity classes.

critical thinking

Although these design principles are formed over decades of experience, it is important to think critically before blindly applying these principles to code.

Anything is too much! Sometimes these principles can go a long way, but in practice sometimes turn into something that is hard to use.

As engineers, we need to critically evaluate the best approach based on unique circumstances, rather than blindly following and applying arbitrary principles.

Cohesion, coupling and separation of concerns

Cohesion

Cohesion represents the clarity of responsibilities within a module, or the complexity of a module.

If our class performs only one task and no other explicit purpose, then the class is highly cohesive. On the other hand, if it is somehow unclear what it is doing, or if it has more than one goal, then its cohesion is very low.

We want the code to be highly cohesive, and if we find that they have a lot of targets, maybe we should split them up.

coupling

Coupling captures the complexity of connecting different classes. We want classes to have as few and simple connections as possible with other classes, so we can exchange them in future events (such as changing network frameworks).

In many programming languages, this is achieved through the heavy use of interfaces, which abstract out classes that handle specific logic, and are then represented as a kind of adaptation layer into which each class can be embedded.

separation of concerns

Separation of Concerns (SoC) is the idea that a software system must be divided into functionally non-overlapping parts. Or the concerns must be distributed in different places, where concerns represent the ability to provide a solution to a problem.

A good example is a web page, which has three layers (information layer, presentation layer, and behavior layer) that are divided into three different places (HTML, CSS, and JS, respectively).

If you revisit our RPG example, you'll see that it had a lot of focus at the very beginning (applying buffs to calculate raid damage, handling assets, gear items, and managing stats). We split those concerns into more intraclusters by decomposition, which abstract and encapsulate their details. Our Hero class is now just a composite object, which is much simpler than before.

Epilogue

Applying these principles to small-scale code may seem complicated. But in fact, these rules are a must for any software project you want to develop and maintain in the future. There is some cost to writing this kind of code in the beginning, but in the long run it pays off several times over.

These principles ensure that our system is more:

  • Extensible: High cohesion makes it easier to implement new modules without caring about unrelated functionality.
  • Maintainable: Low coupling ensures that changes to one module usually do not affect other modules. High cohesion ensures that changes to a system requirement only require changes to as few classes as possible.
  • Reusability: High cohesion ensures that the functionality of a module is complete and well-defined. Low coupling makes modules depend as little as possible on other parts of the system, which makes it easier to reuse modules in other software.

In this article, we first introduced some classes of high-level objects (entity objects, boundary objects, and control objects). Then we learned about some of the key principles used when building objects, such as abstraction, generalization, decomposition, and encapsulation. Finally, we introduce two software quality metrics (coupling and cohesion), and then learn the benefits of using these principles.

I hope this article has provided some overview of design principles, and there are more specific operations that we need to know if we want to make further progress in this area.

Original address: medium.freecodecamp.org/a-short-ove…


Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=325388019&siteId=291194637