Про объектную модель в JavaScript написано много замечательных статей. Да и про различные способы создания приватных членов класса в интернете полно достойных описаний. А вот про protected методы — данных очень немного. Я бы хотел восполнить этот пробел и рассказать, как можно создать protected методы без библиотек на чистом JavaScript ECMAScript 5.
В этой статье:
Ссылка на git-hub репозиторий с исходный кодом и тестами.
Если коротко, то
Напомню, что основная идея protected членов заключается в том, чтобы скрыть методы и свойства от пользователей экземпляра класса, но при этом позволить производным классам иметь к ним доступ.
Использование TypeScript'a не позволит вызывать защищенные методы, однако, после компиляции в JavaScript, все приватные и защищенные члены становятся публичными. Например, мы разрабатываем контрол или библиотеку, которые пользователи будут устанавливать на свои сайты или приложения. Эти пользователи смогут делать с защищенными членами все, что хотят, нарушая целостность класса. В итоге, наш баг-трекер ломится от жалоб, что наша библиотека или контрол работают неправильно. Мы тратим время и силы на то, чтобы разобраться — «это каким-таким образом объект оказался в том состоянии у клиента, что привело к ошибке?!». Поэтому, чтобы облегчить всем жизнь, нужна такая защита, которая не будет давать возможность изменять значение приватных и защищенных членов класса.
Для понимания метода объявления protected членов класса необходимо уверенное знание:
Про устройство объектной модели в JavaScript могу порекомендовать, например, прекрасную статью Андрея Акиньшина(DreamWalker) «Понимание ООП в JS [часть №1]».
Про приватные свойства есть хорошее и, на мой взгляд, достаточно полное описание аж 4-х различных способов создания приватных членов класса на сайте MDN.
Что касается метода Object.defineProperty, он позволит нам скрыть свойства и методы из for-in циклов, и, как следствие, от алгоритмов сериализации:
Подобное сокрытие необходимо выполнять, но этого, конечно же, не достаточно т.к. еще остается возможность вызвать метод/свойство напрямую:
Для начала нам потребуется класс ProtectedError, который наследуется от Error, и который будет выбрасываться, если нет доступа к защищенному методу или свойству.
Теперь, когда у нас есть класс ProtectedError и мы понимаем что делает Object.defineProperty со значением enumerable: false, давайте разберем создание базового класса, который хочет разделить метод protectedMethod со всеми своими производными классами, но спрятать от всех остальных:
Возможно вас смутит проверка:
В таких случаях поступайте, как хотите. Можете, например, сгенерировать ошибку:
А можете просто создать экземпляр корректно, как это сделано в BaseClass.
Далее мы сохраняем новый экземпляр в переменную _self (зачем это нужно поясню чуть позже).
Входя в метод, вызываем проверку контекста на котором нас вызвали. Лучше проверку вынести в отдельный метод, например, checkAccess, т.к. одна и та же проверка понадобится во всех защищенных методах и свойствах классах. Так вот, первым делом проверяем тип контекста вызова «this». Если this имеет тип отличный от BaseClass, значит, тип — ни сам BaseClass, и ни один из его производных. Запрещаем подобные вызовы.
Каким образом такое может произойти? Например, так:
В случае производных классов выражение this instanceof BaseClass будет истинным. Но и для экземпляров BaseClass выражение this instanceof BaseClass будет истинным. Поэтому, чтобы отличить экземпляры класса BaseClass от экземпляров производных классов проверяем конструктор. Если конструктор совпадает с BaseClass, значит, наш protectedMethod вызывают на экземпляре BaseClass, как обычный публичный метод:
Запрещаем подобные вызовы:
Далее идет вызов замкнутого метода protectedMethod, который, собственно, и является защищаемым нами методом. Внутри метода, если возникает потребность обратиться к членам класса BaseClass, можно это сделать, используя сохраненный экземпляр _self. Именно для этого _self и был создан, чтобы иметь доступ к членам класса из всех замкнутых/приватных, методов. Поэтому, если в вашем защищенном методе или свойстве не нужно обращаться к членам класса, то можете не создавать переменную _self.
Внутри класса BaseClass к protectedMethod надо обращаться только по имени, а не через this. Иначе, внутри protectedMethod мы не сможем отличить, вызвали ли нас как публичный метод или изнутри класса. В данном случае замыкание нас спасает — protectedMethod ведет себя как обычный приватный метод, замкнутый внутри класса и видимый только внутри области видимости функции BaseClass.
Теперь давайте рассмотрим производный класс и как сделать в нем доступ к защищенному методу базового класса.
В производном классе мы создаем объект _base, в котором размещаем ссылку на метод protectedMethod базового класса, замкнутую на контекст производного класса через стандартный метод bind. Это значит, что вызов _base.protectedMethod(); внутри protectedMethod this будет не объектом _base, а экземпляром класса DerivedClass.
В классе DerivedClass обязательно нужно объявить публичный метод protectedMethod таким же образом, как мы делали в базовом классе через Object.defineProperty и проверить в нем доступ, вызывая метод checkAccess или совершая проверку прямо в методе:
Проверяем — «а не вызвали ли нас как простой публичный метод?» У экземпляров класса DerivedClass конструктор будет равен DerivedClass. Если это так, то генерируем ошибку. Иначе — отправляем в базовый класс и уже он сделает все остальные проверки.
Итак, в производном классе у нас две функции. Одна объявлена через Object.defineProperty и нужна для классов производных от DerivedClass. Она публичная и потому в ней есть проверка, запрещающая публичные вызовы. Второй метод находится в объекте _base, который замкнут внутри класса DerivedClass и потому не виден никому извне и именно он используется для доступа к защищенному методу из всех методов DerivedClass.
Со свойствами работа происходит чуть по другому. Свойства в BaseClass определяются как обычно через Object.defineProperty, только в геттерах и сеттерах нужно вначале добавить нашу проверку т.е. вызвать checkAccess:
Внутри класса BaseClass к защищенному свойству обращаемся не через this, а к замкнутой переменной _protectedProperty. В случае, если нам важно, чтобы отрабатывал геттер и сеттер при использовании свойства внутри класса BaseClass, тогда нужно создать приватные методы getProtectedPropety и setProtectedProperty, внутри которых не будет проверок, и их уже вызывать.
В производных классах работа со свойствами чуть посложнее, т.к. нельзя свойству подменить контекст. Поэтому, мы воспользуемся стандартным методом Object.getOwnPropertyDescriptor, чтобы из свойства базового класса получить геттер и сеттер как функции, которым уже можно поменять контекст вызова:
И последнее, что хотелось бы прокомментировать — наследование DerivedClass от BaseClass. Как вы возможно знаете, DerivedClass.prototype = new BaseClass(); не только создает прототип, но и переписывает его свойство constructor. Из-за чего у каждого экземпляра DerivedClass свойство constructor становится равным BaseClass. Чтобы исправить это, обычно, после создания прототипа, переписывают свойство constructor:
Однако, чтобы никто не переписал это свойство после нас, используем все тот же Object.defineProperty. Свойство configurable: false запрещает переопределять свойство повторно:
В этой статье:
- Зачем нужны защищенные члены класса
- Что нужно для понимания представленного метода
- Вспомогательный класс ProtectedError
- Реализация protected членов (методов и свойств) для классов объявленных как функция (ECMAScript 5)
Ссылка на git-hub репозиторий с исходный кодом и тестами.
Зачем нужны защищенные члены класса
Если коротко, то
- проще понимать работу класса и находить в нем ошибки. (Сразу видно в каких case'ах используются члены класса. Если приватные — то анализировать надо только данный класс, ну, а если защищенные — то только данный и производные классы.)
- легче управлять изменениями. (Например, можно убирать приватные члены, не опасаясь, что сломается что-то вовне редактируемого класса.)
- уменьшается количество заявок в bug-трекере, т.к. пользователи библиотеки или контрола могут «зашиться» на наши «приватные» члены, которые в новой версии класса мы решили убрать, либо изменить логику их работы.
- И в целом, защищенные члены класса — это инструмент проектирования. Хорошо иметь его под рукой отлаженным и хорошо протестированным.
Напомню, что основная идея protected членов заключается в том, чтобы скрыть методы и свойства от пользователей экземпляра класса, но при этом позволить производным классам иметь к ним доступ.
Использование TypeScript'a не позволит вызывать защищенные методы, однако, после компиляции в JavaScript, все приватные и защищенные члены становятся публичными. Например, мы разрабатываем контрол или библиотеку, которые пользователи будут устанавливать на свои сайты или приложения. Эти пользователи смогут делать с защищенными членами все, что хотят, нарушая целостность класса. В итоге, наш баг-трекер ломится от жалоб, что наша библиотека или контрол работают неправильно. Мы тратим время и силы на то, чтобы разобраться — «это каким-таким образом объект оказался в том состоянии у клиента, что привело к ошибке?!». Поэтому, чтобы облегчить всем жизнь, нужна такая защита, которая не будет давать возможность изменять значение приватных и защищенных членов класса.
Что нужно для понимания рассматриваемого метода
Для понимания метода объявления protected членов класса необходимо уверенное знание:
- устройства классов и объектов в JavaScript.
- способов создания приватных членов класса (как минимум через замыкание).
- методов Object.defineProperty и Object.getOwnPropertyDescriptor
Про устройство объектной модели в JavaScript могу порекомендовать, например, прекрасную статью Андрея Акиньшина(DreamWalker) «Понимание ООП в JS [часть №1]».
Про приватные свойства есть хорошее и, на мой взгляд, достаточно полное описание аж 4-х различных способов создания приватных членов класса на сайте MDN.
Что касается метода Object.defineProperty, он позволит нам скрыть свойства и методы из for-in циклов, и, как следствие, от алгоритмов сериализации:
function MyClass(){ Object.defineProperty(MyClass.prototype, 'protectedNumber', { value: 12, enumerable: false }); this.publicNumber = 25; }; var obj1 = new MyClass(); for(var prop in obj1){ console.log('property:' prop); //prop никогда не будет равен 'protectedNumber' } console.log(JSON.stringify(obj1)); // Выведет { 'publicNumber': 25 }
Подобное сокрытие необходимо выполнять, но этого, конечно же, не достаточно т.к. еще остается возможность вызвать метод/свойство напрямую:
console.log(obj1.protectedNumber); // Выведет 12.
Вспомогательный класс ProtectedError
Для начала нам потребуется класс ProtectedError, который наследуется от Error, и который будет выбрасываться, если нет доступа к защищенному методу или свойству.
function ProtectedError(){ this.message = "Encapsulation error, the object member you are trying to address is protected."; } ProtectedError.prototype = new Error(); ProtectedError.prototype.constructor = ProtectedError;
Реализация protected членов класса в ES5
Теперь, когда у нас есть класс ProtectedError и мы понимаем что делает Object.defineProperty со значением enumerable: false, давайте разберем создание базового класса, который хочет разделить метод protectedMethod со всеми своими производными классами, но спрятать от всех остальных:
function BaseClass(){ if (!(this instanceof BaseClass)) return new BaseClass(); var _self = this; // Замыкаем экземпляр класса, чтобы в будущем не зависеть от контекста /** @summary Проверяет доступ к защищенным членам класса */ function checkAccess() { if (!(this instanceof BaseClass)) throw new ProtectedError(); if (this.constructor === BaseClass) throw new ProtectedError() } Object.defineProperty(_self, 'protectedMethod', { enumerable: false, // скроим метод из for-in циклов configurable:false, // запретим переопределять это свойство value: function(){ // Раз мы здесь, значит, нас вызвали либо как публичный метод на экземпляре класса Base, либо из производных классов checkAccess.call(this); // Проверяем доступ. protectedMethod(); } }); function protectedMethod(){ // Если нужно обратиться к членам данного класса, // то обращаемся к ним не через this, а через _self return 'example value'; } this.method = function (){ protectedMethod(); // правильный способ вызова защищенного метода из других методов класса BaseClass //this.protectedMethod(); // Неправильный способ вызова, т.к. он приведет к выбросу исключения ProtectedError } }
Описание конструктора класса BaseClass
Возможно вас смутит проверка:
Эта проверка «на любителя». Можете ее убрать, к protected методам она не имеет отношения. Однако, лично я в своем коде ее оставляю, т.к. она нужна для тех случаев, когда экземпляр класса создается некорректно, т.е. без ключевого слова new. Например, вот таким образом:if (!(this instanceof BaseClass)) return new BaseClass();
var obj1 = BaseClass(); // или так: var obj2 = BaseClass.call({});
В таких случаях поступайте, как хотите. Можете, например, сгенерировать ошибку:
if (!(this instanceof BaseClass)) throw new Error('Wrong instance creation. Maybe operator "new" was forgotten');
А можете просто создать экземпляр корректно, как это сделано в BaseClass.
Далее мы сохраняем новый экземпляр в переменную _self (зачем это нужно поясню чуть позже).
Описание публичного свойства с именем protectedMethod
Входя в метод, вызываем проверку контекста на котором нас вызвали. Лучше проверку вынести в отдельный метод, например, checkAccess, т.к. одна и та же проверка понадобится во всех защищенных методах и свойствах классах. Так вот, первым делом проверяем тип контекста вызова «this». Если this имеет тип отличный от BaseClass, значит, тип — ни сам BaseClass, и ни один из его производных. Запрещаем подобные вызовы.
if(!(this instanceof BaseClass)) throw new ProtectedError();
Каким образом такое может произойти? Например, так:
var b = new BaseClass(); var someObject = {}; b.protectedMethod.call(someObject); // В этом случае, внутри protectedMethod this будет равен someObject и мы это отловим, т.к. someObject instanceof BaseClass будет ложным
В случае производных классов выражение this instanceof BaseClass будет истинным. Но и для экземпляров BaseClass выражение this instanceof BaseClass будет истинным. Поэтому, чтобы отличить экземпляры класса BaseClass от экземпляров производных классов проверяем конструктор. Если конструктор совпадает с BaseClass, значит, наш protectedMethod вызывают на экземпляре BaseClass, как обычный публичный метод:
var b = new BaseClass(); b.protectedMethod();
Запрещаем подобные вызовы:
if(this.constructor === BaseClass) throw new ProtectedError();
Далее идет вызов замкнутого метода protectedMethod, который, собственно, и является защищаемым нами методом. Внутри метода, если возникает потребность обратиться к членам класса BaseClass, можно это сделать, используя сохраненный экземпляр _self. Именно для этого _self и был создан, чтобы иметь доступ к членам класса из всех замкнутых/приватных, методов. Поэтому, если в вашем защищенном методе или свойстве не нужно обращаться к членам класса, то можете не создавать переменную _self.
Вызов защищенного метода внутри класса BaseClass
Внутри класса BaseClass к protectedMethod надо обращаться только по имени, а не через this. Иначе, внутри protectedMethod мы не сможем отличить, вызвали ли нас как публичный метод или изнутри класса. В данном случае замыкание нас спасает — protectedMethod ведет себя как обычный приватный метод, замкнутый внутри класса и видимый только внутри области видимости функции BaseClass.
Описание производного класса DerivedClass
Теперь давайте рассмотрим производный класс и как сделать в нем доступ к защищенному методу базового класса.
function DerivedClass(){ var _base = { protectedMethod: this.protectedMethod.bind(this) }; /** @summary Проверяет доступ к защищенным членам класса */ function checkAccess() { if (this.constructor === DerivedClass) throw new ProtectedError(); } // Переопределим метод для всех Object.defineProperty(this, 'protectedMethod', { enumerable: false, // т.к. мы создаем свойство на конкретном экземпляре this configurable: false,// то нужно опять запретить переопределение и показ в for-in циклах // Теперь можем объявлять анонимный метод value: function(){ checkAccess.call(_self); return _base.protectedMethod(); } }); // Использование защищенного метода базового класса в производном this.someMethod = function(){ console.log(_base.protectedMethod()); } } DerivedClass.prototype = new BaseClass(); Object.defineProperty(DerivedClass.prototype, 'constructor', { value : DerivedClass, configurable: false });
Описание конструктора производного класса
В производном классе мы создаем объект _base, в котором размещаем ссылку на метод protectedMethod базового класса, замкнутую на контекст производного класса через стандартный метод bind. Это значит, что вызов _base.protectedMethod(); внутри protectedMethod this будет не объектом _base, а экземпляром класса DerivedClass.
Описание метода protectedMethod внутри класса DerivedClass
В классе DerivedClass обязательно нужно объявить публичный метод protectedMethod таким же образом, как мы делали в базовом классе через Object.defineProperty и проверить в нем доступ, вызывая метод checkAccess или совершая проверку прямо в методе:
Object.defineProperty(DerivedClass.prototype, 'protectedMethod', { enumerable: false, configurable: false, value: function(){ if(this.constructor === DerivedClass) throw new ProtectedError() return _base.protectedMethod(); } });
Проверяем — «а не вызвали ли нас как простой публичный метод?» У экземпляров класса DerivedClass конструктор будет равен DerivedClass. Если это так, то генерируем ошибку. Иначе — отправляем в базовый класс и уже он сделает все остальные проверки.
Итак, в производном классе у нас две функции. Одна объявлена через Object.defineProperty и нужна для классов производных от DerivedClass. Она публичная и потому в ней есть проверка, запрещающая публичные вызовы. Второй метод находится в объекте _base, который замкнут внутри класса DerivedClass и потому не виден никому извне и именно он используется для доступа к защищенному методу из всех методов DerivedClass.
Защита свойств
Со свойствами работа происходит чуть по другому. Свойства в BaseClass определяются как обычно через Object.defineProperty, только в геттерах и сеттерах нужно вначале добавить нашу проверку т.е. вызвать checkAccess:
function BaseClass(){ function checkAccess(){ ... } var _protectedProperty; Object.defineProperty(this, 'protectedProperty', { get: function () { checkAccess.call(this); return _protectedProperty; }, set: function (value) { checkAccess.call(this); _protectedProperty = value; }, enumerable: false, configurable: false }); }
Внутри класса BaseClass к защищенному свойству обращаемся не через this, а к замкнутой переменной _protectedProperty. В случае, если нам важно, чтобы отрабатывал геттер и сеттер при использовании свойства внутри класса BaseClass, тогда нужно создать приватные методы getProtectedPropety и setProtectedProperty, внутри которых не будет проверок, и их уже вызывать.
function BaseClass(){ function checkAccess(){ ... } var _protectedProperty; Object.defineProperty(this, 'protectedProperty', { get: function () { checkAccess.call(this); return getProtectedProperty(); }, set: function (value) { checkAccess.call(this); setProtectedProperty(value); }, enumerable: false, configurable: false }); function getProtectedProperty(){ // Делаем полезную работу return _protectedProperty; } function setProtectedProperty(value){ // Делаем полезную работу _protectedProperty = value; } }
В производных классах работа со свойствами чуть посложнее, т.к. нельзя свойству подменить контекст. Поэтому, мы воспользуемся стандартным методом Object.getOwnPropertyDescriptor, чтобы из свойства базового класса получить геттер и сеттер как функции, которым уже можно поменять контекст вызова:
function DerivedClass(){ function checkAccess(){ ... } var _base = { protectedMethod: _self.protectedMethod.bind(_self), }; var _baseProtectedPropertyDescriptor = Object.getOwnPropertyDescriptor(_self, 'protectedProperty'); // объявляем защищенное свойство на объекте _base // чтобы внутри класса DerivedClass обращаться к защищенному свойству Object.defineProperty(_base, 'protectedProperty', { get: function() { return _baseProtectedPropertyDescriptor.get.call(_self); }, set: function(value){ _baseProtectedPropertyDescriptor.set.call(_self, value); } }) // Здесь же мы объявляем свойство публичным, чтобы у классов производных от DerivedClass была возможность добраться до защищенного метода. Object.defineProperty(_self, 'protectedProperty', { get: function () { checkAccess.call(_self); return base.protectedProperty; }, set: function (value) { checkAccess.call(_self); _base.protectedProperty = value; }, enumerable: false, configurable: false }); }
Описание наследования
И последнее, что хотелось бы прокомментировать — наследование DerivedClass от BaseClass. Как вы возможно знаете, DerivedClass.prototype = new BaseClass(); не только создает прототип, но и переписывает его свойство constructor. Из-за чего у каждого экземпляра DerivedClass свойство constructor становится равным BaseClass. Чтобы исправить это, обычно, после создания прототипа, переписывают свойство constructor:
DerivedClass.prototype = new BaseClass(); DerivedClass.prototype.constructor = DerivedClass;
Однако, чтобы никто не переписал это свойство после нас, используем все тот же Object.defineProperty. Свойство configurable: false запрещает переопределять свойство повторно:
DerivedClass.prototype = new BaseClass(); Object.defineProperty(DerivedClass.prototype, 'constructor', { value : DerivedClass, configurable: false });
