Protected методы в JavaScript ES5

    Про объектную модель в JavaScript написано много замечательных статей. Да и про различные способы создания приватных членов класса в интернете полно достойных описаний. А вот про protected методы — данных очень немного. Я бы хотел восполнить этот пробел и рассказать, как можно создать protected методы без библиотек на чистом JavaScript 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


    Возможно вас смутит проверка:

      if (!(this instanceof BaseClass))
         return new BaseClass(); 
    
    Эта проверка «на любителя». Можете ее убрать, к protected методам она не имеет отношения. Однако, лично я в своем коде ее оставляю, т.к. она нужна для тех случаев, когда экземпляр класса создается некорректно, т.е. без ключевого слова new. Например, вот таким образом:

    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
    });
    
    • +11
    • 4,4k
    • 8
    Поделиться публикацией
    Комментарии 8
      +6
      Самопальная реализация правильного ООП в JS? Кажется, в этом месяце ещё не было.
        +5
        Когда я был молод и горяч, у меня частенько возникало желание исправить несправедливость в мире программирования и реализовать недостающую функциональность там, где, как мне казалось, ей самое место, ведь она так прекрасно работает в другом языке/контексте/начальных условиях. Но со временем я подрос и понял, что всему свое место. Как, знаете, в поговорке «нафига козе баян?». Подходы, которые замечательно работают в ООП с настоящими классами, не факт, что нужны при прототипном наследовании, и наоборот. Более того, каждый раз читая статью про попытку приблизить JS к языкам, в которых честное ООП закладывалось в архитектуру языка изначально, или в которой в JS пытаются добавить то, что там совсем не нужно просто потому, что в JS уже есть все инструменты для решения той же самой задачи, но по-другому, и глядя на ту сложность кода, с помощью которого это все предлагается реализовать, я в очередной раз убеждаюсь в своей правоте. Не нужно в JS эмулировать защищенные методы) А в вашей реализации это еще и вредно, потому как добавляет необоснованной сложности в полезный код и накладные расходы при выполнении.

        > проще понимать работу класса и находить в нем ошибки. (Сразу видно в каких case'ах используются члены класса. Если приватные — то анализировать надо только данный класс, ну, а если защищенные — то только данный и производные классы.)

        Чтобы проще понимать работу класса, не стоит использовать длинные цепочки наследования и стараться делать взаимодействие между классами как можно яснее. Также можно использовать композицию вместо наследования. Еще вариант — принять на уровне code convention, что все свойства, которые начинаются с нижнего подчеркивания (_) — защищенные. Это поможет еще и визуально упростить понимание, как можно использовать метод/свойство при одном только взгляде на его имя. И все это без той сложности, которую добавляет ваша реализация.

        > легче управлять изменениями. (Например, можно убирать приватные члены, не опасаясь, что сломается что-то вовне редактируемого класса.)

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

        > уменьшается количество заявок в bug-трекере, т.к. пользователи библиотеки или контрола могут «зашиться» на наши «приватные» члены, которые в новой версии класса мы решили убрать, либо изменить логику их работы.

        Чтобы такого не было, обычно пишут документацию для внешних пользователей, в которой как раз и описывается API библиотеки/контрола/etc, которые можно использовать. Все остальное — нельзя, потому как может поменяться. Ну а имена свойств с нижнего подчеркивания помогут выделить различие еще и визуально.
        Еще обычно на каждый баг в трэкере пишут сценарий воспроизведения, из которого часто понятно, что и где использовали и почему оно так могло получиться. Там же сразу и станет ясно, использовали ли недокументированные функции.

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

        Согласен, но если это реализовано и поддерживается на уровне языка, с соответствующими оптимизациями в нативном коде и с минимальными накладными расходами при выполнении. Но в вашем случае это только добавляет сложности в реальном коде и нагрузки при его выполнении.
          +1
          enumerable: false, // скроим метод из for-in циклов

          Ну, как код скроен, так и носится :)
          В целом, ваш метод покроя не одобряю )))

          ПС. Ну очень уж опечатка к статье подходит)
            +3

            Я уж было думал что высокое искусство накостылить Очередные Единственные Труъ Классы в JS отомрёт с выходом и повсеместным распространением ES6/2015 и повсеместным же распространением TypeScript и .d.ts. — ан нет, жив курилка. И двух недель не прошло с обсуждения пропозала о приватных свойствах...

              +4

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

                0

                Нужно начинать с того что любая попытка реализации аспектов ООП в JS есть костыль.

                  0
                  Из долгих дебатов на тему нужны ли в классах члены с protected-доступом мне показалось разумным аргумент, что вместо того, чтобы разделять приватные члены с производными классами (а ведь protected-доступ, по сути, именно это и означает), нужно создавать новые классы и использовать композицию. И эти новые классы, можно было бы разделять между производными классами.
                  т.е. вместо
                  function Base(){
                      //@protected как бы делаем метод защищенным и разделяем его с производными классами
                      function protectedMethod(){}
                  }
                  

                  разумно сделать
                  function SomeSpeciaFunctionality(){
                      this.method = function (){ // useful code 
                      }
                  }
                  function Base(){
                       var _compositionInstance = new SomeSpeciaFunctionality();
                       function myPrivateFunction(){
                          _compositionInstance.method();
                       }
                  }
                  function DerivedClass(){
                       var _compositionInstance = new SomeSpeciaFunctionality();
                       function anotherPrivateFunction(){
                          _compositionInstance.method();
                       }
                  }
                  
                    0
                    Однако не всегда получается красиво так выделить функционал в отдельный класс. Иногда, бывает так, что приватный метод, который нужно сделать protected сильно завязан на члены класса Base.

                    И ладно еще если метод зависит от публичных членов класса Base, тогда можно было бы вызывать метод в контексте Base, таким образом
                    function SomeSpeciaFunctionality(){
                        this.method = function (){ // useful code 
                        }
                    }
                    function Base(){
                         var _self = this;
                         var _compositionInstance = new SomeSpeciaFunctionality();
                         function myPrivateFunction(){
                            _compositionInstance.method.call(_self);
                         }
                    }
                    

                    хуже, когда защищаемый метод зависит от приватных, т.е. замкнутых членов класса Base.
                    Хотя и это не смертельно, можно отказаться от реализации приватных членов через замыкание и использовать замкнутый приватный контекст и на нем уже вызывать:
                    function SomeSpeciaFunctionality(privateMembers){
                        this.method = function (){ // useful code 
                        }
                    }
                    function Base(){
                         var _self = this;
                         var _privateMembers ={
                               myPrivateVariable: 12
                         }
                         var _compositionInstance = new SomeSpeciaFunctionality(_privateMembers);
                         function myPrivateFunction(){
                            _compositionInstance.method.call(_self);
                         }
                    }
                    

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

                  Самое читаемое