Pull to refresh

Попытка классификации и анализа существующих подходов к наследованию в Javascript

Reading time9 min
Views3K

Некоторое время назад у меня дошли руки до темы, которая давно уже меня нервирует интересует. Эта тема — наследование в JavaScript.

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

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

Общие рассуждения


Есть мнение, что JS — язык очень мощный и гибкий. С другой стороны, есть мнение, что JS — ну, скажем мягко, язык недоделанный. Что ж, когда дело доходит до ООП, JavaScript показывает все, на что способен — по обоим пунктам.

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

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

Вопросы


Вместо этого я решил использовать следующие три вопроса, которые, как кажется, помогают «сфокусировать взгляд» на наследовании в JS:

1) Как реализованы классы
2) Как экземпляры получают свойства своего класса
3) Как реализовано наследование классов

(Примечание: здесь и далее, под свойствами я понимаю как собственно свойства, так и методы объектов — вообще-то, в прототипном программировании есть подходящий термин «слот», но он редко применяется в данном контексте в JS)

Как организуем классы?


Есть два принципиально разных ответа на этот вопрос:

  1. Классы организованы с помощью функций конструкторов.
    При этом, объекты-экземпляры создаются с помощью оператора new, а сами конструкторы, в характерном для JS стиле, совмещают сразу несколько ролей — одновременно и функции, и класса, и конструктора объектов.
  2. Классы организованы с помощью функций фабрик объектов.
    При этом экземпляры непосредственно возвращаются функциями-фабриками.

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

      // Пример 1 - конструкоторы (свойства хранятся в объекте)

      function Class() {
          this.value = 'some value';        // свойство
          this.some_method = function() {   // метод
              console.log('some_method invoked');
          }
      }

      var obj1 = new Class(); // экземпляр класса
      var obj2 = new Class(); // еще один экземпляр
    

А вот аналогичный класс, организованный с помощью фабрики объектов:

      // Пример 2 - фабрики (свойства хранятся в объекте)

      function Class() {
          var obj = {};
          obj.value = 'some value';         // свойство
          obj.some_method = function () {   // метод
              console.log('some_method invoked');
          }
          return obj;
      }

      var obj1 = Class(); // экземпляр (обратите внимание, в данном случае мы не используем new)
      var obj2 = Class(); // еще один
    

(Можно заметить, что наличие специального ключевого слова new намекает, что именно функции-конструкторы являются мейнстримом языка)

Как храним свойства?


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

Тут, прежде всего необходимо пояснить, что понимается под «получением свойств класса». Мы привыкли считать, что объекты автоматически «обладают» свойствами и методами, определенными в их классе. Факт этого «обладания» мы воспринимаем как нечто заданное. Но в JS это не так. Здесь мы можем выбрать каким образом наши объекты будут получать свойства от своих (псевдо) классов. У нас опять две возможности: либо объекты получают свойства своего класса в прототипе, либо содержат их непосредственно.

Что касается первого варианта, то мы только что его наблюдали — два предыдущих примера реализуют непосредственное хранение свойств в экземпляре для функций-конструкторов (пример 1) и для фабрик объектов (пример 2).

Как же выглядит получение свойств из прототипа? А вот как:

      // Пример 3 - функции-конструкторы, свойства в прототипе

      function Class() { }

      Class.prototype.value = 'some value'; // свойство
      Class.prototype.some_method = function() { // метод
         console.log('some_method invoked');
      }

      var obj1 = new Class(); // экземпляр класса
      var obj2 = new Class(); // еще один
    

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

Как видете, с конструкторами прототипы сочетаются довольно естественно. А что с фабриками объектов? С ними ситуация сложнее, так как, согласно стандарту, прототип объекта задается с помощью его функции-конструктора. Иными словами, мы не можем работать с прототипом объекта, если не пользуемся конструкторами. Так что нам придется использовать трюк:

      // Пример 4 - фабрики, свойства в прототипе

      // Вспомогательная функция создает новый объект, 
      // прототип которого равен объекту, полученному ею в качестве параметра.
      function derivate(o) {
        function F() {}
        F.prototype = o;
        return new F();
      }

      function Class() { return derivate(Class.obj); }

      // создаем объект, который будет прототипом для всех экземпляров данного класса
      Class.obj = {};
      Class.obj.value = 'some value';         // свойство
      Class.obj.some_method = function () {   // метод
        console.log('some_method invoked');
      }

      var obj1 = Class(); // экземпляр
      var obj2 = Class(); // еще один
    

Этот пример несколько сложнее предыдущих. Есть ли смысл в этой сложности? Ну, как минимум, пример интересен тем что функция derivate() расширяет именно прототипные возможности языка (ну, или компенсирует недостачу этих возможностей). С другой стороны интересно, что код, отвечающий за создание класса, получился очень похож на аналогичный код из предыдущего примера. Фактически, мы вместо встроенного Class.prototype создали собственное свойство Class.obj, при этом кое-что из того, чем JavaScript обеспечил нас в предыдущем примере, нам пришлось делать самим. Хочу заметить, что при всем своем сходстве, два примера вполне различны по сути.

Для чего вообще нужно помещать свойства класса в прототип экземпляра? Как минимум, таким образом мы можем сэкономить рессурсы, так как одноименные свойства всех экземпляров класса физически будут занимать одно и то же место в памяти.

Конструкторы. Как наследуем?


Наконец, мы подошли к самому интересному. Третий вопрос — наследование.

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

Сам JavaScript реализует наследование делегированием – коротко говоря, это значит, что отсутствующее свойство объекта ищется среди свойств его прототипа.

Наследование копированием соответствует концепции конкатенации прототипов. В этом случае свойства из родительского объекта просто копируются в объект дочерний. Конкатенация прототипов не является частью JavaScript, но не значит что ее нельзя реализовать

Решение с call/apply

Давайте посмотрим как можно реализовать наследование копированием. Теоретически, для этого можно было бы создать вспомогательную фунцию copyMethods:

      // Вспомогательная функция, копирующая методы одного объекта в другой
      function copyMethods(from, to) {
        for(m in from) { // Цикл по всем свойствам прототипа источника
          if (typeof from[m] != "function") continue; // Игнорировать все, что не является функциями
            to[m] = from[m];            // Заимствовать метод
        }
      }
    

Однако можно сделать гораздо проще, если воспользоваться маленьким фокусом с методом apply.

      // Пример 5 - конструкторы + свойства внутри экземпляров + наследование копированием

      // ОПИСАНИЕ ИЕРАРХИИ КЛАССОВ

      // классы организуются с помощью функций конструкторов
      function Class(val) {
        // свойства объектов хранятся непосредственно в самих объектах
        this.value = val;
        this.getName = function() {
          return "Class(id = " + this.getId() + ")";
        }
        this.getId = function() { return 1; }
      }

      function SubClass() {
        // для реализации наследования копированием, можно было бы сделать так:
        // copyMethods(new Class(arguments[0]), this);

        // но есть способ лучше:
        Class.apply(this, arguments); // вызываем Class() ОТ ИМЕНИ this со всеми переданными нам аргументами

        var super_getName = this.getName; // так нужно запоминать свойства родителя, которые нам понадобятся
        this.getName = function() {
          return "SubClass(id = " + this.getId() + ") extends " + super_getName.call(this);
        }
        this.getId = function() { return 2; }
      }

      // ТЕСТИРУЕМ

      o = new SubClass(5);

      console.log(o.value);        // 5
      console.log(o.getName());      // "SubClass(id = 2) extends Class (id = 2)"
      console.log(o instanceof Class); // false [не совместимо с instanceof]
    

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

С учетом законченности этого решения, имеет смысл оценить его плюсы и минусы. Итак,

Чем это хорошо:

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

Чем это плохо:

– не эффективно используется память (все одинаковые свойства всех объектов хранятся как копии)
– не удобно вызывать родительские методы
– не совместимо с оператором instanceof
– не совместимо с нативными классами (нельзя создать потомка встроенного класса вроде Date)

Почти стандартный подход

Разумеется, пример 1 (конструкторы + свойства класса внутри экземпляра), можно развить и вдругом направлении, в соответствии со вторым вариантом ответа ответа на вопрос о способе наследования.

      // Пример 6 - конструкторы + свойства внутри экземпляров + наследование делегированием

      // ОПИСАНИЕ ИЕРАРХИИ КЛАССОВ

      // классы организуются с помощью функций конструкторов
      function Class(val) {
        // свойства объектов хранятся непосредственно в самих объектах
        this.value = val;
        this.getName = function() {
          return "Class(id = " + this.getId() + ")";
        }
        this.getId = function() { return 1; }
      }

      function SubClass() {
        var super_getName = this.getName; // запоминаем свойство родителя
        this.getName = function() {
          return "SubClass(id = " + this.getId() + ") extends " + super_getName.call(this);
        }
        this.getId = function() { return 2; }
      }
      SubClass.prototype = new Class(); // обеспечиваем наследование через прототипы

      // ТЕСТИРУЕМ

      o = new SubClass(5);

      console.log(o.value);        // undefined [конструкторы не наследуются]
      console.log(o.getName());      // "SubClass(id = 2) extends Class (id = 2)"
      console.log(o instanceof Class); // true [совместимо с instanceof]
    

Обратите внимание, что дочерний класс не знает как ему задавать начальные параметры. Есть ли какие-то преимущества у такого подхода, по сравнению с предыдущим? Что ж, наследование через прототипы более экономно расходует память. С другой стороны, это решение явно половинчато — свойства класса-то мы по-прежнему создаем для каждого экземпляра независимо друг от друга.

Совсем стандартный подход

Наконец мы подошли к решению, которое вероятно было задумано как стандартное для JavaScript. Его «формула» с точки зрения наших трех вопросов выглядит так: конструкторы + свойства класса в прототипе + наследование делегированием.

      // Пример 7 - конструкторы + свойства в прототипах + наследование делегированием

      // ОПИСАНИЕ ИЕРАРХИИ КЛАССОВ

      // классы организуются с помощью функций конструкторов
      function Class(val) { this.init(val); }
      Class.prototype.init = function (val) { this.value = val; }
      Class.prototype.value = 5; // значение по умолчанию
      Class.prototype.getName = function() {
          return "Class(id = " + this.getId() + ")";
      }
      Class.prototype.getId = function() { return 1; }

      function SubClass(val) { this.init(val); }
      SubClass.prototype = new Class(); // наследование делегированием
      SubClass.prototype.class_getName = SubClass.prototype.getName; // запоминаем свойство родителя
      SubClass.prototype.getName = function() {
          return "SubClass(id = " + SubClass.prototype.getId() + ") extends " + SubClass.prototype.class_getName();
      }
      SubClass.prototype.getId = function() { return 2; }

      function SubSubClass(val) { this.init(val); }
      SubSubClass.prototype = new SubClass();
      SubSubClass.prototype.subclass_getName = SubSubClass.prototype.getName; // запоминаем свойство родителя
      SubClass.prototype.getName = function() {
          return "SubSubClass(id = " + SubSubClass.prototype.getId() + ") extends " + SubSubClass.prototype.subclass_getName();
      }

      // ТЕСТИРУЕМ

      o = new SubSubClass(5);

      console.log(o.value);            // 5
      console.log(o.getName());        // "SubSubClass(id = 2) extends SubClass(id = 2) extends Class(id = 2))"
      console.log(o instanceof Class); // true [совместимо с instanceof]
    

Я так хотел подчеркнуть неудобство этого примера, что описал в нем целых три класса. Кажется что весь пример состоит из повторения одних и тех же имен, одних и тех же многословных конструкций. Но этого мало, сам способ наследования прототипа содержит в себе принципиальную неэффективность: для того чтобы описать иерархию мы должны создавать и инициализировать (т.е. вызывать метод init) объекты-прототипы. Это плохо, хотя бы потому, что цинициализация может быть ресурсоемкой.

Как ни странно, несмотря на недостатки, этот подход пользуется большой популярностью. Почему? На самом деле тут нет никакой загадки: недостатки метода компенсируются с помощью всяческих надстроек, а вот его «нативность», встроенность в синтаксис языка, никакой надстройкой не обеспечишь.

Что же, у нас остался последний вариант из четверки решений с конструкторами: конструкторы + свойства в прототипах + наследование копированием. Я оставлю его в качестве самостоятельного упражнения для тех кто хочет на себе испытать что значит программировать против ветра ;) Скажу только, что теоретически такой подход возможен, и даже иногда вымучивается используется (во всяком случае, я встречал его в сети), но крайне неудобен (использование с call/apply оказывается невозможным, а наследовать свойства копированием, после того как вы их сохранили в прототипе – просто не логично)

Таким образом, с конструкторами мы разделались, о фабриках объектов — в продолжении.

Update: к сожалению, по прошествии более чем года после написания первой части статьи, я вынужден признать, что на вторую часть у меня банально нет времени — и, судя по всему, не будет. Приношу извинения всем заинтересованным лицам :)
Tags:
Hubs:
+41
Comments44

Articles

Change theme settings