Продвинутый JavaScript 2. Объектно-ориентированное программирование

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

Класс

Описывая функцию-конструктор мы практически создали класс:

function Connection() {
    this.isOpened = false;
    this.isPermanent = false;
    this.toggleState = function(newState) {
        this.isOpened = newState;
    }
}
var myConnection = new Connection;

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

Класс — это шаблон кода, позволяющий создавать однотипные объекты.

Внутренний и внешний интерфейс (инкапсуляция)

В объектно-ориентированном программировании различают:

Приватные свойства/методы класса реализуются с помощью локальных переменных внутри функции конструктора. Публичные свойства/методы — это те, что добавлены в this.

Геттеры и сеттеры

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

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

function Connection(port) {
    this.port = port;
}
var myConnection = new Connection(80);
...
myConnection.port = 666; // перебросили порт "вручную"

Но соединение на таком порту может не поддерживаться нашим Connection, потому следует добавить сеттер для корректной установки порта.

function Connection(port) {
    // port в LE конструктора
    this.setPort = function(newPort) {
        if([80,81,20,21].indexOf(newPort) == -1) {
            throw new Error("Choose correct port number!");
            return;
        }
        this.port = newPort;
    }
}
var myConnection = new Connection(80);
// ...
myConnection.setPort(81); // проверили и поменяли, всё ок
myConnection.setPort(666); // Ошибка

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

function Connection (port) {
    // port в LE конструктора
    // ...
    this.getPort = function() {
        return port;
    }
}
var myConnection = new Connection(80);
// ...
myConnection.getPort(); // 80

Геттер-сеттер

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

function Connection (port) {
    // port в LE конструктора
    this.forPort = function() {
        if(arguments.length > 0) {
            port = arguments[0];
            return true;
        }
        return port;
    }
};

Функциональное наследование

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

Например, нам необходимо описать классы для автомобилей и мотоциклов.

function Car (wheels, maxSpeed, passengers) {
    var material = "metal";
    var maxSpeed = maxSpeed || 0;
    this.wheels = wheels;
    this.passengers = passengers;
    this.signal = function() { alert("Beep!"); };
}

function Bike (wheels, maxSpeed, type) {
    var material = "metal";
    var maxSpeed = maxSpeed || 0;
    this.wheels = wheels;
    this.type = type;
    this.signal = function() { alert("Beep!"); };
}

Одинаковые свойства и методы удобно выделить в отдельный класс Vehicle.

function Vehicle (wheels, maxSpeed) {
    var material = "metal";
    var maxSpeed = maxSpeed || 0;
    this.wheels = wheels;
    this.signal = function() { alert("Beep!"); };
}

Для наследования от Vehicle необходимо вызвать его конструкторе наследующего класса, передав в качестве контекста this:

function Car (wheels, maxSpeed, passengers) {
    Vehicle.apply(this, arguments); // наследование
    this.passengers = passengers;
}

Использование arguments позволяет "прокинуть" аргументы в конструктор класса-родителя, присвоив свойствам результирующего объекта нужные значения.

Такое наследование называется функциональным, т.к. оно происходит через запуск функции-конструктора родительского класса.

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

function Car (wheels, maxSpeed, passengers) {
    Vehicle.apply(this, arguments);
    this.passengers = passengers;
    this.getMaterial = function () {
        alert(material); // ошибка!
    };
}

Ошибка возникает потому, что приватное свойство metarial в виде локальной переменной осталось в Lexical Environment класса-родителя Vehicle и оно недоступно за его пределами. В этом случае принято использовать защищенные свойства.

Защищённые свойства

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

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

function Vehicle (wheels, maxSpeed) {
    this._material = "metal";
    var maxSpeed = maxSpeed || 0;
    this.wheels = wheels;
    this.signal = function() { alert("Beep!"); };
}
function Car (wheels, maxSpeed, passengers) {
    Vehicle.apply(this, arguments);
    this.passengers = passengers;
    this.getMaterial = function () {
        alert(this._material); // работает
    };
}
var myCar = new Car(4, 180, 3);
myCar.getMaterial(); // хорошо
myCar._material; // плохо

Переопределение методов (полиморфизм)

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

Для переопределения достаточно переназначить функцию соответствующего метода:

function Vehicle (wheels, maxSpeed) {
    var maxSpeed = maxSpeed || 0;
    this.wheels = wheels;
    this.sound = "Beep!";
    this.signal = function() { alert(this.sound); };
}
function Bus (wheels, maxSpeed, passengers) {
    Vehicle.apply(this, arguments);
    this.passengers = passengers;
    this.signal = function () { // переопределяем
        console.log("Tuuuuu!");
    };
}
var myBus = new Bus(8, 100, 42);
myBus.signal(); // "Tuuuuu!"

Если поведение унаследованного метода надо расширить, то делают его "декорирование" (не настоящее декорирование, но по смыслу похоже на него):

function Vehicle (wheels, maxSpeed) {
    var maxSpeed = maxSpeed || 0;
    this.wheels = wheels;
    this.sound = "Beep!";
    this.signal = function() { alert(this.sound); };
}
function Scooter (wheels, maxSpeed, passengers) {
    Vehicle.apply(this, arguments);
    this.passengers = passengers;
    var savedSignal = this.signal;
    this.signal = function () { // дополняем
        this.blink();
        savedSignal.apply(this);
    };
    this.blink = function () {
        console.log("Lights is turned off!");
        console.log("Lights is turned on!");
    };
}
var myScooter = new Scooter(2, 80, 1);
myScooter.signal(); // "Beep!" + blink()

При вызове скопированной функции необходимо явно указывать контекст this, иначе простой запуск savedSignal() выведет undefined из-за потери контекста.

Опредение типа данных

Оператор typeof

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

typeof function() {} // function
typeof {} // object
typeof [] // object

Проверка на массив

Специально для массивов в ES5 был добавлен метод проверки массивов Array.isArray():

Array.isArray(1); // false
Array.isArray([]); // true
Array.isArray({0:1, length:1}); // false

Очевидно, использование метода ограничено использованием только для проверки массивов.

Скрытое свойство [[Class]]

Во всех встроенных объектах есть специальное свойство [[Class]], в котором хранится информация о его типе или конструкторе. Квадратные скобки указывают на то, что свойство напрямую извне, но получить его можно, воспользовавшись методом toString объекта Object.

function classOf (obj) {
    return {}.toString.call(obj);
}
classOf(1); // [object Number]
classOf({}); // [object Object]
classOf([]); // [object Array]

Недостатком нашего classof является невозможность опрделить принадлежность какому-либо классу.

Оператор instanceof

Для проверки принадлежности объекта какому-либо конструктору можно воспользоваться оператором instanceOf:

function JustConstructor() {};
var fromConstructor = new JustConstructor;
var vagrant = {};

fromConstructor instanceof JustConstructor; // true
vagrant instanceof JustConstructor; // false
fromConstructor instanceof Object; // true
vagrant instanceof Object; // true

Недостатком этого оператора является то, что мы можем только узнать, является ли конкретный объект сущностью указанного класса, но не можем узнать название класса для конкретного объекта. Более того, instanceof возвращает инфомацию с учетом наследования, т.е. проверка anyObject instanceof Object всегда вернет true как для объекта-из-конструктора, так для объекта-"бродяги".

Утиная типизация

Еще один подход грубого определения типа называется утиной типизацией и основан на пословице:

Если это выглядит как утка, плавает как утка и крякает как утка, то это, возможно, и есть утка.

Метод определяет не тип объекта, а доступные свойства/методы объекта, и если искомые свойства/методы присутствуют, то делают вывод о типе объекта.

var list = [];
if (list.slice && list.forEach) {
    console.log("Похоже на массив");
}

Для утиной типизации можно использовать более универсальную функцию, первый парметр которой — объект для проверки, а остальные — свойства или методы для проверки:

function duckTyping() {
    var objToTest = arguments[0];
    for (var i = 1; i < arguments.length; i++) {
        if (!(arguments[i] in objToTest)) {
            return false;
        }
    }
    return true;
}

duckTyping([], 'slice', 'forEach'); // true