You get a bonus - 1 coin for daily activity. Now you have 1 coin

Object-Oriented Programming (OOP) by Example

Lecture



OOP (Object-Oriented Programming) has become an integral part of developing many modern projects, but despite its popularity, this paradigm is far from the only one.

Object-Oriented Programming (OOP) — is a programming paradigm in which the main element is objects, rather than functions or procedures as in procedural programming. An object is an entity that contains data and the methods for processing it, which helps improve the modularity, scalability, and maintainability of code.

The basic concepts of OOP were developed in the 1960s and formed the basis of many modern programming languages, such as Python, Java, C++, C#, Ruby, and others. Today OOP is widely used to develop software systems of varying complexity: from simple applications to large software systems.

Basic Principles of OOP

OOP rests on four fundamental principles: encapsulation, inheritance, polymorphism, and abstraction. Each of them plays an important role in creating a flexible and convenient architecture.

  1. Encapsulation

    Encapsulation — is a principle according to which an object's data is hidden from outside interference and can only be changed through that object's own methods. This way, access to an object's internal state can be controlled and protected from direct modification.

    Example in Python:

    class BankAccount: def __init__(self, balance): self.__balance = balance # Private field def deposit(self, amount): if amount > 0: self.__balance += amount else: print("The amount must be positive") def get_balance(self): return self.__balance account = BankAccount(1000) account.deposit(500) print(account.get_balance()) # 1500 
  2. Inheritance

    Inheritance lets one class use the functionality of another class, sparing the programmer the need to rewrite code. It also helps structure the class hierarchy, making the code more understandable and manageable.

    Example in Python:

    class Animal: def speak(self): pass class Dog(Animal): def speak(self): return "Woof" class Cat(Animal): def speak(self): return "Meow" animals = [Dog(), Cat()] for animal in animals: print(animal.speak()) # Output: "Woof" and "Meow" 
  3. Polymorphism

    Polymorphism allows the same method to be used for objects of different classes, with each class implementing that method in its own way. This greatly simplifies the code, making it more universal and flexible.

    Example in Python:

    class Bird: def fly(self): return "The bird is flying" class Airplane: def fly(self): return "The airplane is flying" def make_it_fly(flying_object): print(flying_object.fly()) make_it_fly(Bird()) # Output: "The bird is flying" make_it_fly(Airplane()) # Output: "The airplane is flying" 
  4. Abstraction

    Abstraction allows only the significant characteristics of an object to be singled out, hiding implementation details. This helps the user focus on the object's key functions without having to worry about the internal logic of how it works.

    Example in Python:

    from abc import ABC, abstractmethod class Shape(ABC):  @abstractmethod def area(self): pass class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height rect = Rectangle(4, 5) print(rect.area()) # Output: 20 

Let's look at what has been said more abstractly - transformers will serve as the examples.

Object-Oriented Programming (OOP) by Example

First of all, it's worth answering why. The object-oriented ideology was developed as an attempt to link an entity's behavior to its data and to project real-world objects and business processes into program code. The idea was that such code would be easier for a person to read and understand, since people are inclined to perceive the surrounding world as a set of interacting objects that lend themselves to a certain classification. Whether the ideologists managed to achieve this goal is hard to say for certain, but as a matter of fact we have a mass of projects in which a programmer will be required to know OOP.

You shouldn't think that OOP will somehow miraculously speed up the writing of programs, or expect a situation where the residents of Villaribo have already rolled an OOP project into production while the residents of Villabajo are still scrubbing away at greasy spaghetti code. In most cases that isn't so, and the time saved isn't at the development stage but at the support stages (extension, modification, debugging, and testing), that is, in the long run. If you need to write a one-off script that won't require any further support, then OOP most likely won't be of much use for that task either. However, a significant part of the life cycle of most modern projects consists precisely of support and extension. The mere presence of OOP does not by itself make your architecture flawless, and can, on the contrary, lead to needless complications.

Sometimes you may run into criticism aimed at the performance of OOP programs. It's true that a slight overhead is present, but it's so slight that in most cases it can be neglected in favor of the benefits. Nevertheless, in hot spots where millions of objects must be created or processed per second in a single thread, it's worth at least reconsidering whether OOP is necessary, because even a minimal overhead at such volumes can noticeably affect performance. Profiling will help you pin down the difference and make a decision. In other cases, say, where the lion's share of performance hinges on IO, giving up objects would be a premature optimization.

By its very nature, object-oriented programming is best explained through examples. As promised, our patients will be transformers. I'm no transformer-ologist, and I haven't read the comics, so in the examples I'll be guided by Wikipedia and imagination.

Classes and Objects


A quick digression right away: an object-oriented approach is possible even without classes, but we'll be looking at, pardon the pun, the classic scheme, where classes — are everything.

The simplest explanation: a class — is a transformer's blueprint, and instances of that class — are specific transformers, for example, Optimus Prime or Oleg. And although they're assembled from the same blueprint and are equally able to walk, transform, and shoot, they each have their own unique state. State — is a set of changing properties. That's why for two different objects of the same class we can observe a different name, age, location, charge level, ammo count, and so on. The very existence of these properties and their types is described in the class.

Thus, a class — is a description of what properties and behavior an object will have. And an object — is an instance with its own state of those properties.

We say “properties and behavior”, but that sounds somewhat abstract and unclear. It will sound more familiar to a programmer this way: “variables and functions”. In fact, “properties” — are just ordinary variables that happen to be attributes of some object (they're called the object's fields). Likewise, “behavior” — is the object's functions (called methods), which are also attributes of the object. The difference between an object's method and an ordinary function is only that a method has access to its own state through the fields.

So, we have methods and properties, which are attributes. How do you work with attributes? In most programming languages the operator for accessing an attribute — is the dot (except for PHP and Perl). It looks roughly like this (pseudocode):

// class declaration using the class keyword
class Transformer(){
    // declaration of field x
    int x

    // declaration of the constructor method (0 will be passed in here a bit further down)
    function constructor(int x){
        // initialization of field x 
        // (the 0 passed to the constructor becomes a property of the object)
        this.x = x
    }

    // declaration of the run method
    function run(){
        // accessing its own attribute via this
        this.x += 1
    }
}

// and now the client code:

// create a new transformer instance with a starting position of 0
optimus = new Transformer(0)

optimus.run() // tell Optimus to run
print optimus.x // will print 1
optimus.run() // tells Optimus to run again
print optimus.x // will print 2


I'll use the following notation in the pictures:

Object-Oriented Programming (OOP) by Example

I decided not to use UML diagrams, considering them not visual enough, even if more flexible.

Object-Oriented Programming (OOP) by Example
Animation No. 1

What do we see in the code?

1. this — is a special local variable (inside methods) that lets an object access its own attributes from within its methods. Note that it's only its own attributes, that is, when a transformer calls its own method or changes its own state. If from the outside the access would look like this: optimus.x, then from the inside, if Optimus itself wants to access its own field x, within its method the access will read: this.x, that is, "I (Optimus) am accessing my own attribute x". In most languages this variable is called this, but there are exceptions too (for example, self)

2. constructor — is a special method that is automatically called when an object is created. A constructor can take any arguments, just like any other method. In every language the constructor is denoted by its own name. In some it's a specially reserved name like __construct or __init__, and in others the constructor's name must match the class name. The purpose of constructors — is to carry out the object's initial initialization, filling in the necessary fields.

3. new — is a keyword that must be used to create a new instance of some class. At that moment the object is created and the constructor is called. In our example, the constructor is passed 0 as the transformer's starting position (this is the initialization mentioned above). The new keyword is absent in some languages, and the constructor is called automatically when you try to call the class as a function, for example like this: Transformer().

4. The constructor and run methods work with the internal state, but in every other respect they're no different from ordinary functions. Even the declaration syntax matches.

5. Classes can have methods that don't need state and, as a consequence, don't need an object to be created. In that case the method is made static.

SRP


(Single Responsibility Principle / the principle of single responsibility / the first SOLID principle). You're likely already familiar with it from other paradigms: «a function should perform only one complete action». This principle also holds for classes: «A class should be responsible for one single task». Unfortunately, with classes it's harder to define the line you need to cross for the principle to be violated.

There are attempts to formalize this principle by describing a class's purpose in a single sentence without conjunctions, but this is a very controversial technique, so trust your intuition and don't go to extremes. There's no need to turn a class into a Swiss Army knife, but spawning a million classes with a single method inside each — is also foolish.

Association


Traditionally, an object's fields can hold not only ordinary variables of standard types, but other objects as well. And these objects can, in turn, hold some other objects, and so on, forming a tree (sometimes a graph) of objects. This relationship is called association.

Let's say our transformer is equipped with a gun. Actually, no, better with two guns. One in each hand. The guns are identical (they belong to the same class, or, if you like, are made from the same blueprint), both are equally able to fire and reload, but each has its own ammo store (its own state). How do we describe this in OOP now? By means of association:

class Gun(){ // declare the Gun class
    int ammo_count // declare the ammo count

    function constructor(){ // constructor
        this.reload() // call our own "reload" method
    }

    function fire(){ // declare the gun's "fire" method
        this.ammo_count -= 1 // use up a round from our own magazine
    }

    function reload(){ // declare the "reload" method
        this.ammo_count = 10 // fill our own magazine with ammo
    }
}

class Transformer(){ // declare the Transformer class
    Gun gun_left // declare the "left gun" field of type Gun
    Gun gun_right // declare the "right gun" field, also of type Gun

    /*
    now the Transformer's constructor takes
    two already-created, specific guns
    as arguments,
    which are passed in from outside
    */
    function constructor(Gun gun_left, Gun gun_right){
        this.gun_left = gun_left // mount the left gun
        this.gun_right = gun_right // mount the right gun
    }

    // declare the Transformer's "fire" method, which first fires...
    function fire(){
        // with the left gun, calling its "fire" method
        this.gun_left.fire()
        // and then with the right gun, calling the same "fire" method
        this.gun_right.fire()
    }
}

gun1 = new Gun() // create the first gun
gun2 = new Gun() // create the second gun
optimus = new Transformer(gun1, gun2) // create the transformer, passing it both guns


Object-Oriented Programming (OOP) by Example
Animation No. 2

this.gun_left.fire() and this.gun_right.fire() — are accesses to child objects, which likewise happen through dots. With the first dot we access our own attribute (this.gun_right), obtaining the gun object, and with the second dot we access the gun object's method (this.gun_right.fire()).

In short: we've built the robot, issued its standard-issue weapons, now let's figure out what's going on here. In this code, one object became a constituent part of another object. That is exactly what association is. It, in turn, comes in two kinds:

1. Composition — the case where, at the transformer factory, while assembling Optimus, both guns are nailed to his hands for good, and after Optimus dies, the guns die along with him. In other words, the life cycle of the child object coincides with the life cycle of the parent.

2. Aggregation — the case where a gun is issued like a handgun to hold, and after Optimus dies this handgun can be picked up by his comrade-in-arms Oleg, who can then take it in his own hand, or pawn it. That is, the life cycle of the child object does not depend on the life cycle of the parent, and it can be used by other objects.

The orthodox Church of OOP preaches to us the fundamental trinity — encapsulation, polymorphism, and inheritance, on which the entire object-oriented approach rests. Let's go through them in order.

Object-Oriented Programming (OOP) by Example

Inheritance


Inheritance — is a mechanism of the system that allows, as paradoxical as it may sound, some classes to inherit the properties and behavior of other classes for further extension or modification.

What if we don't want to stamp out identical transformers, but instead want a common framework with different add-ons? OOP lets us get away with this by splitting the logic into similarities and differences, then moving the similarities into a parent class and the differences into descendant classes. What does this look like?

Optimus Prime and Megatron — are both transformers, but one is an Autobot and the other a Decepticon. Let's say the differences between Autobots and Decepticons come down only to the fact that Autobots transform into cars, while Decepticons — into aircraft. All other properties and behavior show no difference whatsoever. In that case we can design an inheritance system like this: the common traits (running, shooting) will be described in the base class «Transformer», and the differences (transformation) in two child classes, «Autobot» and «Decepticon».

class Transformer(){ // base class
    function run(){
        // code responsible for running
    }
    function fire(){
        // code responsible for shooting
    }
}

class Autobot(Transformer){ // child class, inherits from Transformer
    function transform(){
        // code responsible for transforming into a car
    }
}

class Decepticon(Transformer){ // child class, inherits from Transformer
    function transform(){
        // code responsible for transforming into an airplane
    }
}

optimus = new Autobot()
megatron = new Decepticon()


Object-Oriented Programming (OOP) by Example
Animation No. 3

This example clearly illustrates how inheritance becomes one way to deduplicate code (the DRY principle) by means of a parent class, while at the same time providing room for mutation in the descendant classes.

Overloading


If, however, you override an already existing method from the parent class inside a child class, overloading kicks in. This lets you not just add to the parent class's behavior, but modify it. When a method is called or an object's field is accessed, the attribute lookup proceeds from the descendant up to the very root — the parent. That is, if you call the fire() method on an autobot, the method lookup is first performed in the child class — Autobot, and since it isn't there, the search moves one step up — to the Transformer class, where it is found and called. It should be noted that this modification violates LSP from the set of SOLID principles, but here we are only considering the technical possibility.

Misuse


Curiously, an excessively deep inheritance hierarchy can produce the opposite effect — added complexity when trying to figure out who inherits from whom, and which method gets called in which case. What's more, not every architectural requirement can be implemented by means of inheritance. So inheritance should be applied without fanaticism. There are recommendations urging you to prefer composition over inheritance wherever that's appropriate. Every piece of criticism of inheritance I've come across is backed by botched examples where inheritance is used as a golden hammer. But that doesn't mean inheritance is inherently always harmful. My addiction counselor used to say the first step — is admitting you have an inheritance addiction.

When describing the relationship between two entities, how do you determine when inheritance is appropriate and when — composition is? You can use a popular rule of thumb: ask yourself, is entity A a kind of entity B? If yes, then inheritance will most likely fit here. If, however, entity A is a part of entity B, then our choice — is composition.

Applied to our situation, this would sound like this:

  1. Is an Autobot a Transformer? Yes, so we choose inheritance.
  2. Is a Gun part of a Transformer? Yes, so — composition.


As a sanity check, try the reverse combination, you'll get nonsense. This cheat sheet helps in most cases, but there are other factors worth relying on when choosing between composition and inheritance. Moreover, these approaches can be combined to solve different kinds of problems.

Inheritance Is Static


Another important difference between inheritance and composition is that inheritance has a static nature and establishes relationships between classes only at the interpretation/compilation stage. Composition, on the other hand, as we saw in the examples, lets you change the relationship between entities on the fly, right at runtime — sometimes this matters a great deal, so it's worth keeping in mind when choosing between the two kinds of relationship (unless, of course, you want to use metaprogramming).

Multiple Inheritance


We looked at a situation where two classes inherit from a common descendant. But in some languages you can also do the opposite — have a single class inherit from two or more parents, combining their properties and behavior. The ability to inherit from several classes instead of just one — is multiple inheritance.

Object-Oriented Programming (OOP) by Example

Generally speaking, in illuminati circles there's an opinion that multiple inheritance — is a sin, bringing with it the diamond problem and confusion with constructors. Besides, the problems that multiple inheritance solves can also be solved by other mechanisms, for example, the interface mechanism (which we'll also talk about). But to be fair, it should be noted that multiple inheritance is convenient for implementing mixins.

Abstract Classes


Besides ordinary classes, some languages also have abstract classes. They differ from ordinary classes in that you cannot create an object of such a class. Why would you need such a class, the reader may ask? It's needed so that descendants can inherit from it — ordinary classes, whose objects can already be created.

Alongside ordinary methods, an abstract class contains abstract methods without an implementation (with a signature, but no code), which must be implemented by the programmer who intends to create a descendant class. Abstract classes aren't mandatory, but they help establish a contract requiring the implementation of a specific set of methods, in order to protect a forgetful programmer from an implementation mistake.

Polymorphism


Polymorphism — is a property of a system that allows multiple implementations of a single interface. None of that makes sense. Let's turn to the transformers.

Let's say we have three transformers: Optimus, Megatron, and Oleg. The transformers are combat units, so they have an attack() method. When the player presses the «fight» button on their joystick, this tells the game to call the attack() method on the transformer the player is playing as. But since the transformers are different, and the game is interesting, each of them will attack in its own particular way. Say, Optimus — is an object of the Autobot class, and Autobots are equipped with guns firing plutonium warheads (no offense meant to transformer fans). Megatron — is a Decepticon, and fires a plasma cannon. Oleg — is a bassist, and he just hurls insults. So what's the benefit?

The benefit of polymorphism in this example is that the game's code knows nothing about how its request gets carried out, who is supposed to attack in what way; its job is simply to call the attack() method, whose signature is the same for all character classes. This lets you add new character classes, or change the methods of existing ones, without changing the game's code. That's convenient.

Encapsulation


Encapsulation — is control over access to an object's fields and methods. By access control we mean not just allowed/not allowed, but also various validations, lazy loading, computations, and other dynamic behavior.

In many languages, part of encapsulation is data hiding. For this purpose there are access modifiers (let's describe the ones found in almost every OOP language):

  • public — anyone who wants to can access the attribute
  • private — only methods of that same class can access the attribute
  • protected — the same as private, except descendants of the class also get access

class Transformer(){
    public function constructor(){ }

    protected function setup(){ }

    private function dance(){ }
}


How do you correctly choose an access modifier? In the simplest case, like this: if the method needs to be accessible to external code, we choose public. Otherwise — private. If there's inheritance, you may need protected for the case where the method shouldn't be callable from outside, but should be callable by descendants.

Accessors (Getters and Setters)


Getters and setters — are methods whose job is to control access to fields. A getter reads and returns a field's value, while a setter — does the opposite, taking a value as an argument and writing it into the field. This makes it possible to equip such methods with additional processing. For example, a setter, when writing a value into an object's field, can check its type, or whether the value falls within the allowed range (validation). A getter, meanwhile, can have lazy initialization or caching added to it, if the actual value in fact lives in a database. You can think of plenty of uses.

In some languages there's syntactic sugar that lets such accessors be disguised as properties, which makes access transparent to external code that has no idea it's working not with a field but with a method, under whose hood an SQL query or a file read is being executed. This is how abstraction and transparency are achieved.

Interfaces


The purpose of an interface — is to lower the level of dependency between entities, by adding more abstraction.

Not every language has this mechanism, but in statically typed OOP languages things would be pretty bad without it. Above we looked at abstract classes, touching on the topic of contracts obliging you to implement certain abstract methods. Well, an interface strongly resembles an abstract class, but it is not a class, just an empty shell listing abstract methods (with no implementation). In other words, an interface has a declarative nature, that is, a pure contract without a drop of code.

Usually, in languages that have interfaces, there's no multiple inheritance of classes, but there is multiple inheritance of interfaces. This lets a class list the interfaces it commits to implementing.

Classes and interfaces are in a «many-to-many» relationship: one class can implement multiple interfaces, and each interface, in turn, can be implemented by many classes.

An interface has a two-sided application:

  1. On one side of the interface — are the classes implementing that interface.
  2. On the other side — are the consumers, who use this interface as a description of the data type they (the consumers) work with.


For example, if some object, besides its main behavior, can be serialized, then let it implement the «Serializable» interface. And if an object can be cloned, then let it implement one more interface — «Cloneable». And if we have some transport module that sends objects over the network, it will accept any objects implementing the «Serializable» interface.

Let's imagine that a transformer's frame is equipped with three slots: a slot for weapons, one for a power generator, and one for some kind of scanner. These slots have particular interfaces: only suitable equipment can be installed into each slot. Into the weapon slot you can install a missile launcher or a laser cannon, into the power generator slot — a nuclear reactor or an RTG (radioisotope thermoelectric generator), and into the scanner slot — a radar or a lidar. The point is that each slot has a universal connection interface, and it's the specific devices that must conform to that interface. For example, motherboards use several types of slots: the CPU slot lets you plug in various processors that fit the given socket, while the SATA slot — accepts any SSD or HDD drive, or even a CD/DVD.

Note that the resulting slot system for the transformers — is an example of using composition. If, however, the equipment in the slots is swappable over the course of the transformer's life, then it's actually aggregation. For clarity, we'll name the interfaces the way it's customarily done in some languages, adding a capital «I» before the name: IWeapon, IEnergyGenerator, IScanner.

// interface declarations:

interface IWeapon{
    function fire() {} // method declaration without an implementation. Same below
}

interface IEnergyGenerator{
    // here there are already two methods that classes will have to implement:
    function generate_energy() {} // the first one
    function load_fuel() {}       // the second one
}

interface IScanner{
    function scan() {}
}


// classes implementing the interfaces:

class RocketLauncher() : IWeapon
{
    function fire(){
        // implementation of launching a missile
    }
}

class LaserGun() : IWeapon
{
    function fire(){
        // implementation of firing the laser
    }
}

class NuclearReactor() : IEnergyGenerator
{
    function generate_energy(){
        // implementation of generating energy with a nuclear reactor
    }

    function load_fuel(){
        // implementation of loading uranium rods
    }
}

class RITEG() : IEnergyGenerator
{
    function generate_energy(){
        // implementation of generating energy with an RTG
    }

    function load_fuel(){
        // implementation of loading RTG pellets
    }
}

class Radar() : IScanner
{
    function scan(){
        // implementation of using radiolocation
    }
}

class Lidar() : IScanner
{
    function scan(){
        // implementation of using optical location
    }
}

// class - the consumer:

class Transformer() {
    // hello, composition:
    IWeapon slot_weapon   // The interfaces are specified as data types.
    IEnergyGenerator slot_energy_generator // They can accept any objects
    IScanner slot_scanner // that implement the specified interface

    /*
    in the method parameters the interface is also specified as the data type,
    the method can accept an object of any class
    that implements the given interface:
    */
    function install_weapon(IWeapon weapon){
        this.slot_weapon = weapon
    }

    function install_energy_generator(IEnergyGenerator energy_generator){
        this.slot_energy_generator = energy_generator
    }

    function install_scanner(IScanner scanner){
        this.slot_scanner = scanner
    }
}

// transformer factory

class TransformerFactory(){
    function build_some_transformer() {
       	transformer = new Transformer()
       	laser_gun = new LaserGun()
       	nuclear_reactor = new NuclearReactor()
       	radar = new Radar()

       	transformer.install_weapon(laser_gun)
       	transformer.install_energy_generator(nuclear_reactor)
       	transformer.install_scanner(radar)

        return transformer
    }
}

// usage

transformer_factory = new TransformerFactory()
oleg = transformer_factory.build_some_transformer()


Object-Oriented Programming (OOP) by Example
Animation No. 4

Unfortunately, the factory didn't fit into the picture, but it's optional anyway; you can assemble a transformer in your own backyard too.

The abstraction layer shown in the picture, in the form of interfaces between the implementation layer and the consumer layer, makes it possible to abstract each from the other. You can observe this by looking at each layer separately: the implementation layer (on the left) doesn't contain a single word about the Transformer class, while the consumer layer (on the right) doesn't contain a single word about the specific implementations (there are no words like Radar, RocketLauncher, NuclearReactor, and so on)

With code like this we can create new components for transformers without touching the blueprints of the transformers themselves. At the same time, the reverse is also true: we can create new transformers by combining already existing components, or add new components without changing the existing ones.

Duck Typing


The phenomenon we observe in the resulting architecture is called duck typing: if something quacks like a duck, swims like a duck, and looks like a duck, then it most likely — is a duck.

Translating this into the language of transformers, it would sound like this: if something fires like a gun, and reloads like a gun, it's most likely a gun. If a device generates energy, it's most likely an energy generator.

Unlike the hierarchical typing of inheritance, with duck typing the transformer couldn't care less what class of gun it was given, or whether it's even a gun at all. What matters is that the thing knows how to shoot! This isn't a virtue of duck typing so much as a trade-off. The reverse situation is also possible, as in the picture below:

Object-Oriented Programming (OOP) by Example

ISP

(Interface Segregation Principle / the interface segregation principle / the fourth SOLID principle) calls for not creating fat, universal interfaces. Instead, interfaces should be split into smaller, specialized ones; this will help combine them more flexibly in implementing classes, without forcing them to implement unnecessary methods.

Abstraction


In OOP, everything revolves around abstraction. There are zealots who insist that abstraction must be part of the OOP trinity (encapsulation, polymorphism, inheritance). My parole officer, on the other hand, used to say the opposite: abstraction is inherent to any kind of programming, not just OOP, so it should stand apart. On the other hand, the same could be said about the other principles too, but you can't change the lyrics of the song. Either way, abstraction is necessary, and especially so in OOP.

Level of Abstraction


Here it's impossible not to quote a well-known joke:
— any architectural problem can be solved by adding an extra layer of abstraction, except for the problem of having too many abstractions.

In our example with interfaces we introduced a layer of abstraction between the transformers and the components, making the architecture more flexible. But at what cost? We had to make the architecture more complex. My therapist used to say that the ability to balance between the simplicity of an architecture and the flexibility of an application — is an art. When choosing the golden mean, you should rely not only on your own experience and intuition, but also on the context of the current project. Since people still haven't learned to see the future, you need to analytically estimate what level of abstraction, and with what probability, might come in handy in a given project, how much time it will take to work out a flexible architecture, and whether the time spent will pay off in the future.

Choosing the wrong level of abstraction leads to one of two problems:

  1. if there isn't enough abstraction, further extensions to the project will run into architectural constraints, which lead either to refactoring and a change of architecture, or to a heap of workarounds (both options usually bring pain and financial losses with them)
  2. if the level of abstraction is too high, this leads to over-engineering in the form of an overly complex architecture that's hard to maintain, and excessive flexibility that will never actually be used in this project. In this situation, even the simplest changes to the project will be accompanied by extra work to satisfy the architecture's requirements (this too sometimes brings a certain amount of pain and financial losses with it)


Object-Oriented Programming (OOP) by Example

It's also important to understand that the level of abstraction isn't determined for the project as a whole, but separately for different components. In some places in the system there may not be enough abstraction, while elsewhere, on the contrary — there's too much of it. However, an incorrect choice of abstraction level can be fixed by timely refactoring. The key word — is timely. Belated refactoring is problematic to carry out once a great many mechanisms have already been built at that level of abstraction. Performing the refactoring ritual on a neglected system can come with a sharp pain in hard-to-reach places for the programmer. It's roughly like replacing the foundation of a house — it's cheaper to build a new house from scratch nearby.

Let's look at how to determine the level of abstraction among the possible options, using a hypothetical game «Transformers Online» as an example. In this case the levels of abstraction will act as layers, with each subsequent layer we examine sitting on top of the previous one, absorbing part of its functionality into itself.

The first layer. The game has a single Transformer class, with all properties and behavior described in it. This is a completely rigid level of abstraction, suitable for a casual game that doesn't call for any particular flexibility.

The second level. The game has a base transformer with core abilities and transformer classes with their own specialization (like a scout, an assault unit, a support unit), described by additional methods. This gives the player a choice, and makes it easier for developers to add new classes.

The third level. In addition to classifying transformers, aggregation is introduced by means of a system of slots and components (as in our example with reactors, guns, and radars). Now part of the behavior will be determined by what gear the player has installed on their transformer. This gives the player even more options for customizing the character's game mechanics, and gives developers the ability to add these very extension modules, which in turn makes it easier for game designers to release new content.

The fourth level. The components themselves can also be given their own aggregation, providing a choice of materials and parts from which those components are assembled. This approach would let the player not just equip their transformers with the needed components, but also manufacture those components themselves out of various parts. I'll admit I've never come across this level of abstraction in games, and not without reason! After all, this comes with a significant increase in architectural complexity, and balancing such games turns into hell. But I don't rule out that such games exist.

Object-Oriented Programming (OOP) by Example

As we can see, each of the layers described, in principle, has a right to exist. It all depends on exactly what kind of flexibility we want to build into the project. If the technical specification says nothing about this, or the project's author doesn't know what the business might require, you can look at similar projects in this field and use them as a guide.

Advantages of OOP

  1. Increased modularity: each object is a separate unit, which makes testing and modifying code easier.
  2. Increased code reuse: inheritance lets you reduce code duplication.
  3. Improved flexibility and scalability: thanks to polymorphism and abstraction, the system can be easily extended.
  4. Ease of maintenance: encapsulation makes it easier to manage changes.

Disadvantages of OOP

  1. Learning difficulty: understanding OOP concepts such as inheritance and polymorphism can require considerable effort.
  2. Performance costs: complex class hierarchies can lead to degraded performance.
  3. Code structure complexity: with improper system design, excessive coupling between objects can arise, complicating the code.

Examples of OOP in Use

OOP is used in various areas of programming:

  • Game industry: for creating game objects (characters, items).
  • Business applications: for creating models of the real world (users, products, orders).
  • Graphical user interface (GUI): components such as buttons, windows, and text fields are implemented as objects.

Design Patterns Used in OOP


Object-Oriented Programming (OOP) by Example

Decades of development have led to the emergence of a list of the most commonly used architectural solutions, which over time were classified by the community and came to be called design patterns. That's exactly why, when I first read about patterns, I was surprised to discover that it turns out I was already using many of them in practice, I just didn't know these solutions had a name.

Design patterns, like abstraction, are characteristic not only of OOP development, but of other paradigms too. In general, the topic of patterns is beyond the scope of this article, but here I'd like to warn the young developer who is only just about to get acquainted with patterns. It's a trap! Let me explain why.

The purpose of patterns — is to help solve architectural problems that have either already surfaced, or will most likely surface as the project develops. So, having read about patterns, a newcomer may develop an irresistible temptation to use patterns not to solve problems, but to create them. And since a developer's desires know no bounds, they may start not solving the task with the help of patterns, but instead twisting any task to fit a pattern-based solution.

Another value that patterns provide — is the formalization of terminology. It's far easier to tell a colleague that a «chain of responsibility» is used here than to spend half an hour drawing the behavior and relationships of objects on a piece of paper.

Design patterns — are universal solutions for the typical problems that arise during software development. They aren't ready-made pieces of code, but rather design recommendations that can be adapted to a specific task. Patterns help make code more flexible, easy to modify, and simple to maintain.

Design patterns are often divided into three groups:

  1. Creational — related to the creation of objects.
  2. Structural — related to the structure of classes and objects.
  3. Behavioral — describe the interaction between objects.

Let's look at the key patterns in each of these categories.

1. Creational Patterns

Creational patterns help create objects flexibly and in a controlled way, allowing you to avoid tight coupling between classes. They also help manage the complexity associated with object creation.

1.1 Factory Method

Description: Defines an interface for creating objects, allowing subclasses to choose the specific type of object being created.

Example in Python:

from abc import ABC, abstractmethod class Product(ABC):  @abstractmethod def use(self): pass class ConcreteProductA(Product): def use(self): return "Using Product A" class ConcreteProductB(Product): def use(self): return "Using Product B" class Creator(ABC):  @abstractmethod def factory_method(self): pass def some_operation(self): product = self.factory_method() return product.use() class ConcreteCreatorA(Creator): def factory_method(self): return ConcreteProductA() class ConcreteCreatorB(Creator): def factory_method(self): return ConcreteProductB() creator_a = ConcreteCreatorA() print(creator_a.some_operation()) # "Using Product A" 

1.2 Abstract Factory

Description: Provides an interface for creating families of related objects without being tied to their specific classes.

2. Structural Patterns

These patterns help organize classes and objects into larger structures, while preserving flexibility and efficiency.

2.1 Adapter

Description: Converts a class's interface into another interface that the client expects. This pattern allows classes with incompatible interfaces to work together.

Example in Python:

class EuropeanPlug: def plug_in_europe(self): return "Plugged in Europe" class Adapter: def __init__(self, european_plug): self.european_plug = european_plug def plug_in_usa(self): return self.european_plug.plug_in_europe() plug = EuropeanPlug() adapter = Adapter(plug) print(adapter.plug_in_usa()) # "Plugged in Europe" 

2.2 Decorator

Description: Allows new functionality to be added to objects dynamically. Decorators wrap the original object and add extra methods or modify existing ones.

3. Behavioral Patterns

Behavioral patterns describe how objects interact with one another and how responsibilities are distributed among them.

3.1 Observer

Description: Creates a subscription mechanism that lets some objects track changes in other objects. It's used when there is one object (the publisher) that sends data to multiple subscribers.

Example in Python:

class Publisher: def __init__(self): self.subscribers = [] def subscribe(self, subscriber): self.subscribers.append(subscriber) def notify_subscribers(self, data): for subscriber in self.subscribers: subscriber.update(data) class Subscriber: def update(self, data): print("Message received:", data) publisher = Publisher() subscriber_a = Subscriber() subscriber_b = Subscriber() publisher.subscribe(subscriber_a) publisher.subscribe(subscriber_b) publisher.notify_subscribers("New message") 

3.2 Command

Description: Encapsulates a request as an object, allowing the client to parameterize objects based on the action, schedule the execution of a command, or support undoing operations.

Examples of Patterns Used in Real Projects

  1. Factory Method: Often used in applications with many kinds of objects, such as document processing or working with various file formats.
  2. Adapter: Very useful when integrating various libraries with incompatible interfaces.
  3. Observer: Widely used for tracking changes in GUI systems, where interaction between elements needs to be dynamic.

Conclusion

OOP — is a powerful programming paradigm that makes code more structured, modular, and easier to maintain. Despite its drawbacks, OOP remains one of the most popular paradigms, and knowing its principles is extremely useful for every programmer.

Under today's requirements, having the word class in your code doesn't make you an OOP programmer. Because if you don't use the mechanisms described in this article (polymorphism, composition, inheritance, etc.), and instead use classes merely to group functions and data, then that isn't OOP. The same thing could be achieved with some namespaces and data structures. Don't confuse the two, or you'll be embarrassed in an interview.

I'd like to end my song with some important words. None of the mechanisms, principles, and patterns described here, nor OOP as a whole, should be applied where it's pointless or could cause harm. That's what leads to articles with strange titles like «Inheritance — the Cause of Premature Aging» or «Singletons May Cause Cancer».

I'm serious. If you look at the case of the singleton, its widespread use without proper understanding has been the cause of serious architectural problems in many projects. And fans of hammering nails with a microscope have kindly dubbed it an anti-pattern. Be sensible.

Unfortunately, there are no one-size-fits-all recipes in design for every situation, for where something is appropriate to apply and where it isn't. This will gradually fall into place in your head with experience.

See Also

  • structured programming
  • Functional programming
  • Logic programming
  • Automata-based programming
  • Procedural programming
  • Object-oriented programming
  • Prototype-based programming
  • Aspect-oriented programming
  • Component-oriented programming

продолжение следует...

Продолжение:


Часть 1 Object-Oriented Programming (OOP) by Example

Comments

To leave a comment

If you have any suggestion, idea, thanks or comment, feel free to write. We really value feedback and are glad to hear your opinion.
To reply

Lectures and tutorial on "Object oriented programming"

Terms: Object oriented programming