Знакомство с ООП на примере JavaScript

в 10:00, , рубрики: abstraction, classes, incapsulation, inheritance, javascript, object composition, polymorphism, ruvds_перевод, Блог компании RUVDS.com, ооп, Программирование

Знакомство с ООП на примере JavaScript - 1


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

Тем же, кто с парадигмами программирования незнаком, я рекомендую начать с краткого введения «Programming Paradigms – Paradigm Examples for Beginners».

За дело!

Знакомство с ООП на примере JavaScript - 2

Содержание

Введение в объектно-ориентированное программирование

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

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

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

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

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

Создание объектов — классы

Каждой видеоигре необходим персонаж, не так ли? При этом все персонажи обладают определёнными характеристиками (свойствами) вроде цвета, роста, имени и т.д., а также способностями (методами) вроде прыжка, бега, удара и т.п. Отличным инструментом для хранения всей подобной информации выступают объекты.

Предположим, что нам доступно три вида персонажей, и мы хотим создать 6 разных, по 2 каждого вида.

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

const alien1 = {
    name: "Ali",
    species: "alien",
    phrase: () => console.log("I'm Ali the alien!"),
    fly: () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}
const alien2 = {
    name: "Lien",
    species: "alien",
    sayPhrase: () => console.log("Run for your lives!"),
    fly: () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}
const bug1 = {
    name: "Buggy",
    species: "bug",
    sayPhrase: () => console.log("Your debugger doesn't work with me!"),
    hide: () => console.log("You can't catch me now!")
}
const bug2 = {
    name: "Erik",
    species: "bug",
    sayPhrase: () => console.log("I drink decaf!"),
    hide: () => console.log("You can't catch me now!")
}
const Robot1 = {
    name: "Tito",
    species: "robot",
    sayPhrase: () => console.log("I can cook, swim and dance!"),
    transform: () => console.log("Optimus prime!")
}
const Robot2 = {
    name: "Terminator",
    species: "robot",
    sayPhrase: () => console.log("Hasta la vista, baby!"),
    transform: () => console.log("Optimus prime!")
}

Заметьте, что все персонажи имеют свойства name и species, а также содержат метод sayPhrase. Более того, у каждого вида есть метод, присущий только ему (например, у пришельцев это метод fly).

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

И такой подход работает. Мы можем без проблем обращаться к свойствам и методам подобным образом:

console.log(alien1.name) // вывод: "Ali"
console.log(bug2.species) // вывод: "bug"
Robot1.sayPhrase() // вывод: "I can cook, swim and dance!"
Robot2.transform() // вывод: "Optimus prime!"

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

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

В качестве рефакторинга написанного выше кода мы можем создать класс для каждого вида персонажей:

class Alien { // Имя класса
    // Конструктор будет получать ряд параметров и присваивать их в качестве свойств создаваемому объекту
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    // Это будут методы объекта
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

class Bug {
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
    sayPhrase = () => console.log(this.phrase)
}

class Robot {
    constructor (name, phrase) {
        this.name = name
        this.phrase = phrase
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
    sayPhrase = () => console.log(this.phrase)
}

Затем из этих классов можно инстанцировать наших персонажей таким образом:

const alien1 = new Alien("Ali", "I'm Ali the alien!")
// Мы используем ключевое слово "new", сопровождаемое именем соответствующего класса,
// и передаём соответствующие параметры, согласно тому, что было объявлено в конструкторе класса

const alien2 = new Alien("Lien", "Run for your lives!")
const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!")
const bug2 = new Bug("Erik", "I drink decaf!")
const Robot1 = new Robot("Tito", "I can cook, swim and dance!")
const Robot2 = new Robot("Terminator", "Hasta la vista, baby!")

После этого мы также можем обращаться к свойствам и методам каждого объекта:

console.log(alien1.name) // output: "Ali"
console.log(bug2.species) // output: "bug"
Robot1.sayPhrase() // output: "I can cook, swim and dance!"
Robot2.transform() // output: "Optimus prime!"

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

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

Что нужно помнить о классах

Согласно этому определению (англ.), выраженному более формально:

«Класс в программе – это определение «типа» кастомной структуры данных, включающей как данные, так и применяемое в их отношении поведение. Классы определяют работу подобных структур данных, но сами при этом конкретными значениями не являются. Для получения значения, которое можно будет использовать в программе, класс нужно инстанцировать (с помощью ключевого слова new) один или более раз».

  • Помните, что классы не являются фактическими сущностями или объектами, а представляют схемы, которые мы используем для их создания.
  • По соглашению имена классов объявляются с первой заглавной буквы и прописываются в camelCase. Ключевое слово class создаёт константу, исключая возможность её дальнейшего переопределения.
  • Классы всегда должны содержать метод-конструктор, который служит для будущего инстанцирования самого класса. В JS конструктор – это простая функция, возвращающая объект. Единственная особенность здесь в том, что при вызове с ключевым словом new она присваивает свой прототип в виде прототипа возвращаемого объекта.
  • Ключевое слово this указывает на сам класс и служит для определения его свойств внутри конструктора.
  • Методы можно добавлять простым определением имени функции и её исполняемого кода.
  • JS – это язык, основанный на прототипах, и внутри него классы используются только в качестве синтаксического сахара. Здесь это особой роли не играет, но лучше сей момент знать и учитывать. Более подробно эта тема раскрыта в статье «JavaScript prototypes and Inheritance – and Why They Say Everything in JS is an Object».

Четыре принципа ООП

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

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

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

Разберём это на примере. Представьте, что все определённые нами выше персонажи будут врагами основного героя. И являясь врагами, они все будут иметь свойство power и метод attack.

Один из вариантов реализовать это – просто добавить указанные свойства и методы во все имеющиеся классы:

...

class Bug {
    constructor (name, phrase, power) {
        this.name = name
        this.phrase = phrase
        this.power = power
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}

class Robot {
    constructor (name, phrase, power) {
        this.name = name
        this.phrase = phrase
        this.power = power
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}

const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 10)
const Robot1 = new Robot("Tito", "I can cook, swim and dance!", 15)

console.log(bug1.power) //вывод: 10
Robot1.attack() // вывод: "I'm attacking with a power of 15!"

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

class Enemy {
    constructor(power) {
        this.power = power
    }

    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power) {
        super(power)
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

...

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

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

При инстанцировании новых объектов мы просто передаём параметры так, будто они объявлены в соответствующем конструкторе и… вуаля! Теперь мы можем обращаться к свойствам и методам, объявленным в родительском классе.

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10)
const alien2 = new Alien("Lien", "Run for your lives!", 15)

alien1.attack() // вывод: I'm attacking with a power of 10!
console.log(alien2.power) // вывод: 15

Далее предположим, что хотим добавить новый родительский класс, группирующий всех наших персонажей (независимо от того, враги они или нет), а также установить свойство speed и метод move. Сделать это можно так:

class Character {
    constructor (speed) {
        this.speed = speed
    }

    move = () => console.log(`I'm moving at the speed of ${this.speed}!`)
}

class Enemy extends Character {
    constructor(power, speed) {
        super(speed)
        this.power = power
    }

    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(power, speed)
        this.name = name
        this.phrase = phrase
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    sayPhrase = () => console.log(this.phrase)
}

Сначала мы объявляем новый родительский класс Character. Затем расширяем его классом Enemy. И, наконец, добавляем новый параметр speed в функции constructor и super класса Alien.

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

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)

alien1.move() // вывод: "I'm moving at the speed of 50!"
console.log(alien2.speed) // вывод: 60

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

class Character {
    constructor (speed) {
        this.speed = speed
    }
    move = () => console.log(`I'm moving at the speed of ${this.speed}!`)
}

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

class Bug extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
}

class Robot extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
}


const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)
const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 25, 100)
const bug2 = new Bug("Erik", "I drink decaf!", 5, 120)
const Robot1 = new Robot("Tito", "I can cook, swim and dance!", 125, 30)
const Robot2 = new Robot("Terminator", "Hasta la vista, baby!", 155, 40)

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

Что нужно помнить о наследовании

  • Класс может наследовать только от одного родителя. Расширять несколько классов нельзя, хотя для этого есть свои хитрости.
  • Вы можете безгранично увеличивать цепочку наследования, устанавливая родительский, «дедовский», «прадедовский» и так далее классы.
  • Если дочерний класс наследует какие-либо свойства от родительского, то он сначала должен присвоить эти свойства через вызов функции super() и лишь затем устанавливать свои.

Пример:

// Это сработает:
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

// Здесь возникнет ошибка:
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        this.species = "alien" // ReferenceError: в производном классе до обращения к ‘this’ или возвращения из производного конструктора необходимо сначала вызвать конструктор super
        super(name, phrase, power, speed)
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

  • При наследовании все родительские методы и свойства переходят к потомку. Здесь мы не можем выбирать, что именно наследовать (так же, как не можем выбирать достоинства или недостатки, получаемые нами от родителей при рождении. К теме выбора мы ещё вернёмся при рассмотрении композиции).
  • Дочерние классы могут переопределять родительские свойства и методы.

В качестве примера в предыдущем фрагменте кода класс Alien расширяет класс Enemy и наследует метод attack, который выводит I'm attacking with a power of ${this.power}!:

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
alien1.attack() // вывод: I'm attacking with a power of 10!

Предположим, что мы хотим изменить действие метода attack в классе Alien. Для этого его можно переопределить, объявив повторно таким образом:

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    attack = () => console.log("Now I'm doing a different thing, HA!") // Override the parent method.
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
alien1.attack() // вывод: "Now I'm doing a different thing, HA!"

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

Инкапсуляция – это ещё один принцип ООП, который означает способность объекта «решать», какую информацию он будет раскрывать для внешнего мира, а какую нет. Реализуется этот принцип через публичные и закрытые свойства и методы.

В JS все свойства объектов и методы по умолчанию являются публичными. «Публичный» означает возможность доступа к свойству/методу объекта извне его тела:

// Вот наш класс
class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

// Вот наш объект
const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)

// Здесь мы обращаемся к публичным свойствам и методам
console.log(alien1.name) // вывод: Ali
alien1.sayPhrase() // вывод: "I'm Ali the alien!"

Для большей наглядности давайте рассмотрим, как выглядят закрытые свойства и методы.

Допустим, нам нужно, чтобы класс Alien имел свойство birthYear и использовал его для выполнения метода howOld, но мы не хотим, чтобы это свойство было доступно вне самого объекта.

Реализовать это можно так:

class Alien extends Enemy {
    #birthYear // Сначала нужно объявить закрытое свойство, используя в начале его имени символ '#'

    constructor (name, phrase, power, speed, birthYear) {
        super(name, phrase, power, speed)
        this.species = "alien"
        this.#birthYear = birthYear // Затем внутри функции конструктора мы присваиваем его значение
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    howOld = () => console.log(`I was born in ${this.#birthYear}`) // и используем его в соответствующем методе
}
    
// Привычным образом выполняем инстанцирование
const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50, 10000)

После этого можно обращаться к методу howOld так:

alien1.howOld() // вывод: "I was born in 10000"

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

console.log(alien1.#birthYear) // Выбрасывает ошибку
console.log(alien1)
// вывод:
// Alien {
//     move: [Function: move],
//     speed: 50,
//     sayPhrase: [Function: sayPhrase],
//     attack: [Function: attack],
//     name: 'Ali',
//     phrase: "I'm Ali the alien!",
//     power: 10,
//     fly: [Function: fly],
//     howOld: [Function: howOld],
//     species: 'alien'
//   }

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

Абстракция

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

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

Полиморфизм

Осталось рассмотреть полиморфизм (звучит заумно, не так ли? В ООП самая крутая терминология…). Полиморфизм означает «множество форм», являясь, по сути, довольно простым принципом, отражающим способность метода возвращать разные значения, согласно определённым условиям.

Например, мы видели, что класс Enemy содержит метод sayPhrase. При этом все классы видов наследуют от класса Enemy, то есть у них тоже есть метод sayPhrase.

Но мы также видим, что этот метод при вызове для разных видов возвращает разные результаты:

const alien2 = new Alien("Lien", "Run for your lives!", 15, 60)
const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 25, 100)

alien2.sayPhrase() // вывод: "Run for your lives!"
bug1.sayPhrase() // вывод: "Your debugger doesn't work with me!"

И причина в том, что при инстанцировании мы передали каждому классу свой параметр. Это один вид полиморфизма – основанный на параметрах.

Другой его вид основывается на наследовании. Он относится к случаям, когда есть родительский класс, который устанавливает метод, впоследствии переопределяемый потомком этого класса.

Здесь также отлично подходит уже виденный нами ранее пример:

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
    attack = () => console.log("Now I'm doing a different thing, HA!") // Переопределение родительского метода
}

const alien1 = new Alien("Ali", "I'm Ali the alien!", 10, 50)
alien1.attack() // вывод: "Now I'm doing a different thing, HA!"

Эта реализация полиморфна, поскольку, если закомментировать метод attack в классе Alien, мы всё равно сможем вызвать его для объекта:

alien1.attack() // вывод: "I'm attacking with a power of 10!"

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

Композиция объектов

Композиция объектов – это техника, которая работает как альтернатива наследованию.

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

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

Предположим, что хотим добавить способность полёта персонажам Bug. Как мы видели в коде, способность fly есть только у Alien. Значит, один из вариантов – это продублировать тот же метод в классе Bug:

class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}

class Bug extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!") // Дублирование кода =(
}

Ещё один вариант – это переместить метод fly наверх в класс Enemy, чтобы его унаследовали и класс Alien, и класс Bug. Но так мы сделаем этот метод доступным для классов, которым он не нужен, например, Robot.

class Enemy extends Character {
    constructor(name, phrase, power, speed) {
        super(speed)
        this.name = name
        this.phrase = phrase
        this.power = power
    }
    sayPhrase = () => console.log(this.phrase)
    attack = () => console.log(`I'm attacking with a power of ${this.power}!`)
    fly = () => console.log("Zzzzzziiiiiinnnnnggggg!!")
}


class Alien extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "alien"
    }
}

class Bug extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "bug"
    }
    hide = () => console.log("You can't catch me now!")
}

class Robot extends Enemy {
    constructor (name, phrase, power, speed) {
        super(name, phrase, power, speed)
        this.species = "robot"
    }
    transform = () => console.log("Optimus prime!")
    // Мне не нужен метод fly =(
}

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

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

const bug1 = new Bug("Buggy", "Your debugger doesn't work with me!", 25, 100)

const addFlyingAbility = obj => {
    obj.fly = () => console.log(`Now ${obj.name} can fly!`)
}

addFlyingAbility(bug1)
bug1.fly() // вывод: "Now Buggy can fly!"

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

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

Вот неплохое видео, в котором сравниваются наследование и композиция:

Обобщение

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

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

Успехов!

Знакомство с ООП на примере JavaScript - 3

Автор: Дмитрий Брайт

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js