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

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

За дело!



Содержание



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


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

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

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

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

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

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


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

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

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

const alien1 = {
    name: "Ali",
    species: "alien",
    sayPhrase: () => 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.️

Успехов!


Комментарии (31)


  1. yarkov
    15.05.2022 13:24
    +2

    По соглашению имена классов объявляются с первой заглавной буквы и прописываются в верблюжьем регистре

    Аж глазу больно стало. Это PascalCase называется.


    1. Bright_Translate Автор
      15.05.2022 13:32
      +1

      Пардон, у автора в оригинале camelCase. Я считал, что вполне допустимо назвать его верблюжьим. Исправил на PascalCase.


    1. napa3um
      15.05.2022 14:32
      +5

      PascalCase (античный термин) и CamelCase - синонимы, в JS также используется и lowerCamelCase, частный случай CamelCase.

      Автор, уберите упоминание Паскаля, не втягивайте читателей в ретроградство :).


      1. Bright_Translate Автор
        15.05.2022 14:57
        +1

        Спасибо, добрый человек. Снова исправил) Для себя на будущее запомнил.


        1. Habivax
          15.05.2022 18:35
          +4

          https://ru.wikipedia.org/wiki/CamelCase

          В языке Java принято использовать UpperCamelCase для именования классов и lowerCamelCase — для именования экземпляров классов и методов.

          https://ru.wikipedia.org/wiki/Соглашения_об_именах_(программирование)#JavaScript

          Встроенные библиотеки JavaScript используют те же соглашения об именах, что и Java.

          https://skillbox.ru/media/code/notatsii-v-programmirovanii/

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

          ...

          PascalCase - Тот же camelCase, но все слова, даже первое, начинаются с заглавной буквы.

          ...

          Иногда Pascal case называют upper camel case или, наоборот, camel case называют low Pascal case.

          IMHO - PascalCase это всегда PascalCase, а вот camelCase без (upper) и (lower) это х.з. что, по этому автор и уточняет регистр первой буквы. Изначально у вас все было правильно, "capital first letter and camelCase" и "с первой заглавной буквы и прописываются в верблюжьем регистре" - формально одно и то же, только общепринятый термин переводить не стоило.

          "Прописываются" - это нотация, т.е. "По соглашению классы именуются в нотации camelCase с первой прописной буквой" (с первой буквой в верхнем регистре) или, что более понятно, "По соглашению классы именуются в нотации UpperCamelCase".

          Снова исправлять ничего не надо, спасибо napa3um-у, уже все хорошо :)


          1. Bright_Translate Автор
            15.05.2022 18:40

            Благодарю за доп. прояснение. Занятный у нас тут экскурс в тему регистров получился..:) Познавательный.


            1. napa3um
              15.05.2022 19:23
              +6

              В комментариях была вскрыта новая фундаментальная проблема отрасли, почти что «табы против пробелов» :).


              1. Habivax
                15.05.2022 20:06
                +1

                Опасную тему затрагиваете однако. Статью про "табы против" с Хабра неспроста убрали. Я так думаю :)


    1. Lazytech
      15.05.2022 17:03

      +1

      В данном случае термины PascalCase и UpperCamelCase кажутся мне более подходящими, чем термин camelCase (да еще и с маленькой буквы), который только вводит в заблуждение. Невольно вспомнилась великолепная лекция Реймонда Хеттингера (Raymond Hettinger, один из разработчиков основных библиотек языка Python), в которой фигурировали слова RED, BLUE, GREEN и др., написанные неправильными цветами. :)

      P.S. Cсылка с таймкодом на соответствующий момент в вышеупомянутой лекции:
      https://youtu.be/wf-BqAjZb8M?t=41


      1. napa3um
        15.05.2022 17:41
        +2

        Верблюжий регистр бывает и CamelCase, и camelCase, и указывает на «горбатое» написании слов. Да, величина первой буквы там обозначается графически :). Если же хочется различить и фонетически, то дополняют префиксом - lowerCamelCase или UpperCamelCase.

        А Pascal[Case] - это древний артефакт, своими корнями уходящий в эпоху становления компиляторов, где обозначались не столько способы написания имён, сколько правила их преобразований при компиляции и бинарные форматы линковки (порядок заталкивания аргументов в стек, например).

        Да и странным бы было писать, например, в одном и том же предложении: «Имена классов принято писать в PascalCase, а инстансы - в camelCase». Как говорится, или трусы, или крестик :). PascalCase не имеет симметричного lowerPascalCase (а если бы имел, то ваша аргументация потеряла бы смысл). Потому кэмел тут однозначно на коне :).


        1. Lazytech
          15.05.2022 18:07
          +1

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

          Вот, к примеру, "JavaScript Naming Conventions":
          https://www.robinwieruch.de/javascript-naming-conventions/

          ЦИТАТА:

          A brief overview about the different case styles:
          • camelCase (used in JS)
          • PascalCase (used in JS)
          • snake_case
          • kebab-case

          Как нетрудно заметить, аж четыре разных термина. Похоже, автор не видит в этом никаких проблем.

          Еще статья, "JavaScript Style Guide":
          https://www.w3schools.com/js/js_conventions.asp

          ЦИТАТА:

          PascalCase:

          PascalCase is often preferred by C programmers.

          camelCase:

          camelCase is used by JavaScript itself, by jQuery, and other JavaScript libraries.

          А вот термины UpperCamelCase и lowerCamelCase мне практически не попадаются. Возможно, не там ищу?

          P.S. Запоздало нашел обсуждение "Clarify we mean UpperCamelCase, not lowerCamelCase by shepmaster · Pull Request #2389 · rust-lang/rfcs"
          https://github.com/rust-lang/rfcs/pull/2389

          Похоже, я таки был неправ касательно популярности термина PascalCase, извиняйте.


          1. napa3um
            15.05.2022 18:19
            +1

            И тем не менее я предлагаю оставить Паскаля в покое, и использовать более удобную «симметричную» терминологию. Да, найти можно что угодно, я свой вариант аргументировал (и он тоже является широко расхожим, не лукавьте, будто не можете нагуглить) :).

            Как нетрудно заметить, аж четыре разных термина. Похоже, автор не видит в этом никаких проблем.

            Автор как раз пытается объять весь зоопарк терминов в этой статье. Мог и на мандаринском термины объяснить, для полноты. Но зачем же их все использовать в своей собственной статье не про стили написания слов? :)

            https://en.m.wikipedia.org/wiki/Camel_case

            P.S.: ????


        1. ReadOnlySadUser
          17.05.2022 15:38

          Я ща может быть магию скажу, но ведь говорить и писать PascalCase и camelCase - гораздо удобнее, чем (впервые мною встреченные тут) варианты с upper/lower.

          Говорить удобнее - потому что не требуются никакие уточнения, эти слова однозначно идентифицируют сущность

          Писать - потому что короче. Писать camelCase надо именно с маленькой буквы, потому что, внезапно, по форме слова можно понять что имеется ввиду. Условно говоря, если на русском напишу: "используйте ВотТакойСтиль для классов, и вотТакойСтиль для переменных", - меня все сразу поймут, не задавая лишних вопросов.


          1. napa3um
            17.05.2022 15:39

            Говорить удобнее так: "используй кэмэл-кейс, классы пиши с большой буквы, переменные - с маленькой". Т.е., уточнение говорится не птичьим языком, а естественным: "кэмэл-кейс с заглавной", "кэмэл-кейс с прописной". И все действительно сразу понимают.


  1. WieRuindl
    15.05.2022 19:23
    +3

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

    Автор, у тебя в самом первом блоке кода объект alien1 имеет метод phrase, а не sayPhrase, как все остальные, исправь, что ли :)


    1. Habivax
      15.05.2022 20:14
      +2

      Наверное на это товарищу Germán Cocca надо указать. Автор тут только переводчик как я понял.

      Germán Cocca
      I'm a full stack dev (javascript | typescript | react | react native | node) and computer science student. Here I write about the things I learn along my path to becoming the best developer I can be.


    1. Bright_Translate Автор
      16.05.2022 05:58

      Готово.


    1. sanchezzzhak
      16.05.2022 20:55

      Вот вам смешно, а мне плакать хочется.

      Весь проект в одном стиле и один джун, не два Ждуна решили через нижние подчёркивание классы назвать. Уже3 год бесит, 300 леонидов из 350. Так что я за обсуждение как правильно делать и не портить нервную систему другим.


  1. vsviridov
    15.05.2022 20:05
    +9

    Какой ужас… Пытаются обьяснить ООП, а все методы класса сделаны как стрелочные ф-ции замыкания… Каждому инстансу по копии ф-ции с замыканием. Верблюд большой — оперативы много ему видней.

    И вообще, JS не ООП как таковой, у него прототипная модель. В статье это где-то посередение и должным образом на это не обращают внимания.


    1. napa3um
      15.05.2022 20:15

      Как я понял, JS тут лишь в качестве "псевдокода" для описания концептуальной модели ООП. Кстати, показан не совсем "настоящий ООП", а, скорее, "Си++ - ООП", ибо "настоящий", говорят, надо где-нибудь в Smalltalk / Objective C / Qt смотреть (или даже, сюрприз, в прототипном JS без "классового" сахара) :). Но мы рискуем нарваться на ещё один древнейший и фундаментальнейший холивар, по сравнению с которым табы с пробелами покажутся детской сказкой (и живые позавидуют мёртвым) :).


      1. vsviridov
        15.05.2022 20:21
        +1

        Основная претензия к стрелочным функциям. Если это статья для начинающих — то это только их запутает. Т.к. нужно сначала тогда обьяснить чем стрелочные отличаются от обычных, семантику this (а это одно из весьма замороченных мест в джаваскрипте)…


        1. Habivax
          15.05.2022 20:25

          Я разработчик полного стека (javascript | typescript | react | react native | node) и студент факультета компьютерных наук. Здесь я пишу о том, чему я учусь на своем пути к тому, чтобы стать лучшим разработчиком, каким я могу быть.

          Студент пишет о том, чему его учат. Все претензии к его преподавателям :)

          Да и статья в оригинале называется "Object-Oriented Programming in JavaScript for Beginners"


          1. vsviridov
            15.05.2022 20:29
            +1

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

            Был как-то эпизод с олимпийским батутом (коим я занимался просто по фану) и рассказывал кому-то (тоже начинающему) про свои идеи и понимания процесса, и подошел опытный инструктор и сказал, что почти все что я рассказал — это неправильно и будет человеку активно мешать добится правильной формы при выполнении элемента… Было очень стыдно потом…


            1. TexxTyRe
              16.05.2022 12:11
              +2

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


        1. napa3um
          15.05.2022 20:32
          +1

          Это статья не про JS, а про ООП. Но я соглашусь с вами, что в век мозаичного мышления и копипасты кода из интернетов надо ответственнее относиться к своим примерам, особенно в "новичковых" статья, ибо каждая неверная буква отразится терабайтами мировой энтропии и триллионами багов и страданий людей :ॐ.


          1. vsviridov
            15.05.2022 20:38

            Несколько лет учил новичков в бут-кемпе. Синдром утенка представлен почти на 100% и плохие техники импринтятся на подкорку на раз, и потом сложно отучивать…


        1. Sovietmade
          16.05.2022 12:10

          просто автор - бунтарь. Если в mdn сказано "не использовать стрелочные функции в качестве методов", то, значит, нужно делать наоборот


  1. mkvmaks
    15.05.2022 20:40

    А мне статья понравилась.


  1. zhekaal
    15.05.2022 23:27
    +1

    Про проблемы с наследованием классно расписано, коротко и ясно. Но композиция это когда один класс содержит один или несколько других или по-другому, состоит из них. А вот функция, которая объекту присваивает поле-функцию, это только в JS, да и работает уже с созданными экземплярами


  1. azatSomeCode
    16.05.2022 05:54
    +5

    довольно странно изучать ооп на примере js


  1. Sovietmade
    16.05.2022 07:35
    +2

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