Skip to content

Instantly share code, notes, and snippets.

@Student-Java
Last active April 10, 2021 21:55
Show Gist options
  • Save Student-Java/4ef558ace30714381d4ad7a3a8b73959 to your computer and use it in GitHub Desktop.
Save Student-Java/4ef558ace30714381d4ad7a3a8b73959 to your computer and use it in GitHub Desktop.
My vision of OOP(Russian lang)

Про ООП

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

ООП придумали для упрощения написания и понимания кода(особенно при командной разработке) и последующей его поддержки. Человек мыслит, воспринимает и запоминает информацию образами или объектами. У себя в голове мы можем сколько угодно "разговаривать", но, когда надо думать быстро, мы используем не слова, а мыслеобразы. До ООП активно использовалось императивное/процедурное программирование, но они хороши когда кодовая база маленькая и код пишет один человек (C/Pascal/Assembler/etc). Когда стало понятно, что код программ начинает превышать разумные пределы которыми человек может оперировать в голове и потребовалось писать код быстро(зачастую частично похожий на то что было написано ранее), то парадигма ООП начала становится популярной, так как она позволяла значительно упростить понимание и поддержку кода программистами, плюс обеспечивала легкое его переиспользование и расширение. Фактически в ООП мы берем объект из реальной жизни или доменной области (бизнес логики) и воплощаем в коде его абстракцию. Например, если встречаешь класс User в коде, то уже +- понимаешь какие поля и функционал у него могут быть, т.е. название класса говорит о его внутреннем устройстве.

Основные принципы ООП

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

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

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

class Foo {
  constructor() {
    this.fooProperty = 1;
  }

  getFoo() {
    return this.fooProperty;
  }
}

class Boo extends Foo {
  constructor() {
    super();
    this.booProperty = 2;
  }
  
  getCombined() {
    return { booProp: this.booProperty, fooProp: this.fooProperty};
  }
}

const boo = new Boo();
console.log(boo.getCombined()); // {booProp: 2, fooProp: 1}
console.log(boo.getFoo()); // 1

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

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

Это механизм, который позволяет скрыть внутреннюю реализацию за публичным API, т.е. мы создаем методы, которые являются общедоступными, при этом мы скрываем внутренние методы и все свойства за геттерами/сеттерами и не позволяем к ним иметь прямой доступ. Это дает очень важную возможность - объявить контракт который не должен меняться (но в жизни бывают исключения) и при этом ничего не говорить о внутренней реализации. Впоследствии внутренняя реализация может кардинально меняться, а доступные методы нашего класса останутся неизменными. Примером может служить Array.prototype.sort() - в зависимости от движка браузера или его версии метод сортировки может быть QuickSort или MergeSort, либо вообще комбинированным, но контракт данного метода неизменен. В JS такого нельзя достигнуть обычными методами классов, но можно сделать при помощи IIFE функций:

export default (function () {
  const cash = new Map();

  return {
    put: (key, value) => {
      cash.set(key, value);
      return value;
    },
    get: key => {
      return cash.get(key)
    },
    has: key => cash.has(key),
    size: () => cash.size
  }
})();

Данный модуль возвращает объект кеша с публичным API, но не дает никакой возможности получить прямой доступ к внутреннему состоянию - Map(). В других ЯП, поддерживающих парадигму ООП, для этого существуют модификаторы доступа, которые ограничивают область видимости свойства или метода класса - например для Java public, protected, package-protected, private
С инкапсуляцией мы сталкиваемся все время, ведь все вокруг нас использует этот принцип. Например: компьютер или человеческое тело - есть публичный интерфейс через который происходит взаимодействие с окружающим миром, а внутренняя реализация за ним скрыта и лучше в нее без надобности не лезть :)

Полиморфизм

Дословно многообразие форм может нести разные проявления - "истинный" :) параметрический полиморфизм, есть в TypeScript - Generics, или ad-hoc полиморфизм который есть как в JS, так и в TS. Этот механизм довольно всеобъемлющ и имеет разные проявления, но в основном в ООП речь идет о возможности задать единый интерфейс(API) класса который гарантированно будет у всех его наследников. Также при этом наследники могут иметь свою реализацию для тех же методов.

const CURRENCIES = {
    USD: 'USD',
    BYN: 'BYN',
    RUR: 'RUR',
    XBT: 'XBT'
}
class Account {
    constructor(currencyService, currency = CURRENCIES.BYN, balance = 100) {
        this.currency = currency;
        this.currencyService = currencyService;
        this.balance = balance;
    }
    getBalance() {
        return this.balance * this.currencyService.getLastQuote(this.currency);
    }
}
class UsdAccount extends Account {
    constructor(currencyService) {
        super(currencyService, CURRENCIES.USD);
        this.currencyService = currencyService;
    }
}
class BitcoinAccount extends Account {
    constructor(currencyService, cryptoCurrencyService) {
        super(currencyService, CURRENCIES.USD);
        this.currencyService = currencyService;
        this.cryptoCurrencyService = cryptoCurrencyService;
    }
    getBalance() {
        return super.getBalance() * this.cryptoCurrencyService.getLastQuote(CURRENCIES.XBT, this.currency);
    }
}
// Дополнительные классы
class CurrencyServiceMock {
    constructor(quotes) {
        this.quotes = new Map().set(this._getCurrentDate(), quotes);
    }
    _getCurrentDate() {
        return new Date().toISOString().slice(0, 10);
    }
    getLastQuote(currency) {
        return this.quotes.get(this._getCurrentDate()).find(c => c[currency])[currency];
    }
}
class CryptoCurrencyServiceMock extends CurrencyServiceMock {
    constructor(quotes) {
        super(quotes);
    }
    getLastQuote(cryptoCurrency, currency) {
        return super.getLastQuote(`${cryptoCurrency}${currency}`);
    }
}
const currencyService = new CurrencyServiceMock([{[CURRENCIES.BYN]: '1'}, {[CURRENCIES.USD]: '2.551234'}]);
const cryptoCurrencyService = new CryptoCurrencyServiceMock([{[`${CURRENCIES.XBT}${CURRENCIES.USD}`]: '15534.014912'}]);
const accounts = [new UsdAccount(currencyService), new BitcoinAccount(currencyService, cryptoCurrencyService)];
accounts.map(acc => acc.getBalance()).forEach(balance => console.log(balance)); 
// Вывод в консоль:
// 255.1234
// 3963090.700000141

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

код ниже фактически написан на псевдокоде для примера и с претензией на строгую типизацию

    class Animal {
        voice() {
            console.log('');
        }
    }

    class Monkey extends Animal { // этот класс является наследником Animal
        voice() {
            console.log('U-ah-ah!');
        }
        
        eatBanana() {
            this.voice();
        }
    }

    class Human { // этот класс не является наследником Animal
        voice() {
            console.log('WTF!');
        }

        doCode() {
            console.log('Tap-tap-tap-tap... WTF!');
        }
    }

    // инициализируем переменную списком типизированным по классу Animal
    const animals = new List<Animal>(); 
    
    /* 
     * В строготипизированном языке, попытка положить в список Animal объект класса, 
     * не являющийся наследником класса Animal, приведет к ошибке типов
    */
    animals.add(new Animal());
    animals.add(new Monkey()); // все ок ошибок нет
    animals.add(new Human()); // ошибка несовпадения типов, которая появится еще в IDE или при компиляции

Если сделать класс Human наследником класса Monkey, то ошибка больше не будет возникать

    class Human extends Monkey { // теперь все ок, наследование соблюдено
        voice() {
            console.log('WTF!');
        }

        doCode() {
            console.log('Tap-tap-tap-tap... WTF!');
        }
    }

    // инициализируем переменную списком типизированным по классу Animal
    const animals = new List<Animal>(); 
    animals.add(new Animal());
    animals.add(new Monkey());
    animals.add(new Human());
    animals.forEach(animal => animal.voice()); 

В данной ситуации класс Animal задает контракт всем своим наследникам - метод voice(). Соответственно, когда мы добавляем объект в типизированный список, то можем с уверенностью(*) сказать, что в рантайме у всех объектов вне зависимости от их положения в иерархии классов будет метод voice(). Фактически когда мы добавляем объект в список происходит неявное восходящее приведение типов(casting, каст) - все объекты кастуются в Animal.

(*) - это не полностью работает для TypeScript, так как по итогу все будет выполняться в динамическом рантайме движка JS и мы можем скомпилировать и запустить код TS несмотря на ошибки

Из ситуации выше вытекает несколько интересных следствий:

    // инициализируем переменную списком типизированным по классу Animal
    const monkeys = new List<Monkey>();  
    monkeys.add(new Monkey());
    monkeys.add(new Human());
    monkeys.forEach(monkey => monkey.eatBanana()); // все ок, у всех объектов есть такой метод

    monkeys.forEach(monkey => monkey.doCode()); // ошибка 
    // ошибка так как при попытке вызове на объекте класса Monkey, не будет такого метода
    // хотя иногда в реале кажется, что так все и происходит, но я не первый, кто 
    // подозревает, что реал написан на JS

    monkeys.add(new Animal()); // ошибка
    // нисходящее преобразование типов не может быть выполнено неявно
    // но код ниже будет работать

    const human = new Human();
    const newMonkeys = new List<Monkey>();  
    newMonkeys.add(human); // неявное преобразование типов
    
    const supposedToBeHuman = newMonkeys.get(0); // здесь все еще Monkey, хотя изначально был Human
    supposedToBeHuman.eatBanana() // все ок
    // supposedToBeHuman.doCode() - будет ошибка!

    const humanAgain = (Human) supposedToBeHuman; // явно кастуем к типу Human
    humanAgain.eatBanana() // все ок
    humanAgain.doCode(); // все ок, ошибок нет, несмотря на несколько преобразований 

    const animal = new Animal();
    const letsCreateNewHuman = (Human) animal; // ошибка! 
    // В бога поиграть не получится, если не было таких свойств изначально, 
    // то мы их кастованием не добавим 

Т.е. механизм полиморфизма позволяет унифицировать код и обрабатывать сходные типы данных одинаковым образом, НО при этом каждый объект может иметь свою собственную реализацию общего API. В примере с Animal и его наследниками реализация общего для всех метода voice() каждая для своего класса, но также у потомков есть свой функционал.
В реале мы используем полиморфизм и общий API повсеместно - API автомобиля, API телефона, API клавиатуры компа и т.д. У нас есть многообразие объектов, но интерфейсы у всех объектов в одной группе +- одинаковые, что позволяет нам ими пользоваться, хотя внутренне они могут быть разными или иметь дополнительные свойства.

Абстракция

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

the end

К теме ООП еще относятся понятия IS-A/HAS-A.

IS-A это наследование
HAS-A агрегация, которая делится на композицию(сильная связь) и ассоциацию(слабая связь)

    class Animal {
        voice() {
            console.log('');
        }
    }

    class Monkey extends Animal { // Monkey IS-A Animal
        voice() {
            console.log('U-ah-ah!');
        }
    }
    class Engine {}

    class Vehicle {
      engine = new Engine(); // Vehicle HAS-A Engine
    } 

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

Еще добавлю, что у ООП есть свои минусы и свои хейтеры(как и у любого другого подхода) и не стоит им бездумно увлекаться. У каждого инструмента есть свое применение.
И еще, рекомендую почитать про существующие парадигмы программирования.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment