Приватные конструкторы JavaScript -4


Введение

Месяц назад вышел новый стандарт ECMAScript 2022, который нам дал всё больше и больше ООП. На Хабре уже была статья про то, что нам представили в новом стандарте, но сегодня я хочу остановиться на том, что мы не получили. Приватные конструкторы в студию.

Разберём, что такое приватные конструкторы, зачем они нужны и попробуем создать полифил.

Кто такой приватный конструктор?

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

А зачем он собственно нужен?

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

class Fahrenheit {
	constructor(value) {
    this.#value = value;
  }

	#value;
}

Данный конструктор предполагает, что при создании класса мы будем передавать значение градусов в Фаренгейтах. Хмм... А что если мы хотим перевести Цильсии в Фаренгейты и создавать Фаренгейты только из Цельсий? Кому это нужно - не знаю, но тем не менее :D

Мы можем сделать так:

class Fahrenheit {
  constructor(value) {
    this.#value = value;
  }
  
  #value;
  
  static fromCelsius(value) {
		return new Fahrenheit(value * 9/5 + 32);
  }
}

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

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

Как найти выход?

Давайте попробуем реализовать приватный конструктор самостоятельно? В качестве инструмента мы будем использовать прокси-объект:

function privateConstructor(cls) {
  // Флаг, который отвечает за то, что конструктор был вызван через метод,
  // а не через оператор new
  let viaMethod = false;
  
  return new Proxy(cls, {
    // Вешаем обработчик на конструктор, чтобы в случае вызова через new
    // выдавать ошибку
    construct: (target, args) => {
      if (!viaMethod) {
        throw new Error('Cannot use "new" for private constructor');
      }
      
      return Reflect.construct(target, args);
    },
    
    // Вешаем обработчик на каждое свойство и каждый метод
    get: (target, key) => {
      let maybeMethod = Reflect.get(target, key);
      
      // Проверяем, метод это или свойство
      if (maybeMethod instanceof Function) {
        // Переключаем флаг вызова через метод
        viaMethod = true;
      }
      
      return maybeMethod;
    }
  });
}

В итоге у нас получилось что-то наподобие декоратора. Однако при такой реализации у нас есть проблема. Давайте рассмотрим её.

Пусть имеется класс A со статическим методом, который создаёт экземпляр класса:

class A {
  static create() {
   	return new A();
  }
}

Теперь применим к классу наш декоратор:

const decorA = privateConstructor(A);

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

// Так нельзя
new decorA();
decorA.create();

// А так можно
decorA.create();
new decorA();

Вот это я молодец - расписал очевидную проблему.

Проблема кроется во флаге viaMethod. Нужно сделать так, чтобы он, после вызова метода, обратно возвращался в значение false. А как? Ответ: используем декоратор. Это последний за сегодня декоратор, обещаю.

Сделаем декоратор, который вызывает переданную функцию после того, как метод был вызван и вычислен:

function callFunctionAfterMethod(method, callback) {
  // Да, снова прокси, он тоже последний
	return new Proxy(method, {
    apply: (target, thisArg, args) => {
      // Вызываем метод вместе с this
      const result = Reflect.apply(target, thisArg, args);
      // Вызываем колбэк
      callback();
      
      return result;
    }
  });
}

А теперь поправим немного наш обработчик get:

function privateConstructor(cls) {
  // Флаг, который отвечает за то, что конструктор был вызван через метод,
  // а не через оператор new
  let viaMethod = false;
  
  return new Proxy(cls, {
    // Вешаем обработчик на конструктор, чтобы в случае вызова через new
    // выдавать ошибку
    construct: (target, args) => {
      if (!viaMethod) {
        throw new Error('Cannot use "new" for private constructor');
      }
      
      return Reflect.construct(target, args);
    },
    
    // Вешаем обработчик на каждое свойство и каждый метод
    get: (target, key) => {
      let maybeMethod = Reflect.get(target, key);
      
      // Проверяем, метод это или свойство
      if (maybeMethod instanceof Function) {
        // Переключаем флаг вызова через метод
        viaMethod = true;
        // Декорируем метод так, чтобы после его вызова флаг менялся на false
        maybeMethod = callFunctionAfterMethod(
          maybeMethod.bind(target),
          () => (viaMethod = false)
        );
      }
      
      return maybeMethod;
    }
  });
}

Выводов не будет

Не знаю, что тут можно написать




Комментарии (32):

  1. nin-jin
    /#24611880 / -8

    Вывод от меня: когда вкручивали async/await, как обычно, не нашлось знающего JS человека, чтобы подсказать горе-стандартизаторам, что функции могут вызываться и через `new`, и неплохо бы предусмотреть асинхронность и в этом месте.

    • Crinax
      /#24612028

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

      • nin-jin
        /#24612354 / -8

        Да и в остальные языки async/await вкручивали люди с тем же уровнем развития. Иначе бы не вкручивали эту чушь вовсе.

    • Kolyaj
      /#24612816 / +1

      await же просто ждёт резолва промиса. Возвращайте из конструктора промис, будет у вас асинхронный конструктор.

      var A = class {
        constructor(foo) {
          this._foo = foo;
          return new Promise((resolve) => {
            setTimeout(() => {
              resolve(this);
            }, 1000);
          })
        }
        
        getFoo() {
          return this._foo;
        }
      };
      
      (async () => {
        var a = await new A(5);
        console.log(a.getFoo());
      })();

      • mayorovp
        /#24612844

        А теперь попробуйте унаследоваться от этого класса и посмотрите какой this придёт в конструктор наследника...


        Впрочем, в других реализациях наследование тоже не особо работает.

        • nin-jin
          /#24612944

          return this.then( self => {
            // и поехали
          } )

          • mayorovp
            /#24613048

            Да, это будет работать. Но выглядит ужасно. И IDE почти наверняка соврёт в подсказках.

  2. k12th
    /#24611892 / +2

    А как все-таки связаны приватные и асинхронные конструкторы?

    • Crinax
      /#24612000

      Если я где-то упоминал про асинхронные конструкторы, то процитируйте, пожалуйста, а-то, вроде, всю статью перечитал - не нашёл :D

      Под "асинхронным созданием экземпляра класса" я имел ввиду, что может случится примерно следующий кейс:

      class Payment {
        constructor(props) {
      		this.#props = props;
        }
        
        #props;
        
        static async fromServer(link) {
          const data = await fetch(link).then(res => res.json());
      
          return new Payment(data);
        }
      }

      Я думал привести подобный пример, но чувство, что меня за это архитекторы ПО в подворотне испинают :D

      • mayorovp
        /#24612074 / +3

        Ага, значит тут мы получаем данные платежа с сервера и создаём на их основе объект.


        А что если мы получили данные платежа из другого источника? Прочитали из файла? Забили константами, потому что у нас модульный тест? В чём проблема обратиться к конструктору когда у нас есть данные платежа?

        • nin-jin
          /#24612450 / -2

          Приведу вам пример из жизни. Вот есть у вас Uint8Array с данными криптографического ключа. Вам нужно создать экземпляр обёртки:

          const key = new Key( data )

          И.. это не компилируется, так как функция импорта ключа - асинхронная. И приходится менять конструктор на фабрику:

          const key = await Key.from( data )

          • mayorovp
            /#24612480

            Функция импорта ключа возвращает CryptoKey. И, скорее всего, именно этот CryptoKey и будет передан в конструктор.


            Внимание, вопрос: есть ли какая-то действительно важная причина запрещать прямой вызов конструктора, если у вызывающей стороны уже каким-то чудом есть корректный CryptoKey?

            • nin-jin
              /#24612692 / -3

              Она принимает 100500 параметров. Собственно для того обёртка и нужна, чтобы не возиться с этими функциями напрямую. И подобное костылеварение ещё долго продолжаться будет, пока все методы высшего порядка и операторы не будут продублированы в асинхронном варианте.

              • mayorovp
                /#24612740

                И что дальше? Я всё ещё не понимаю как из 100500 параметров некоторой функции следует необходимость сделать конструктор класса приватным.

                • nin-jin
                  /#24612752 / -2

                  Ну а я не вижу причин что либо вообще приватным делать. При чём тут это вооще?

                  • mayorovp
                    /#24612764 / +1

                    Смотрите, я написал что конструктор не обязательно делать приватным. Вы начали спорить.


                    Но если вы не видите причин делать что-либо вообще приватным, то о чём вообще спор-то? Какую мысль вы пытаетесь донести?

  3. BraveDev
    /#24611942 / +1

    ВСЁ это — синтаксический сахар. Ничего нового, просто удобства.

  4. mayorovp
    /#24612040 / +5

    Слишком сложно.


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


    Однако, зачем в принципе может понадобиться скрывать конструктор? Рассмотрим всё тот же класс Fahrenheit. Если вызвать его конструктор, то случится … что? Мы успешно преобразуем "фаренгейты" в "фаренгейты"? Звучит как совершенно корректная операция.


    С асинхронными "конструкторами" та же самая история. Я ещё не видел ситуаций, когда бы действительно необходимо было скрывать конструктор.


    Однако, если скрыть конструктор всё же нужно — почему бы не скрыть весь класс?


    class Fahrenheit {
      constructor(value) {
        this.#value = value;
      }
    
      #value;
    }
    Fahrenheit.prototype.constructor = null;
    
    export function fromCelsius(value) {
      return new Fahrenheit(value * 9/5 + 32);
    }

    • Crinax
      /#24612082

      Звучит на самом деле логично. Я, если честно, сам не встречался с тем, что где-то нужен был приватный конструктор. Может быть я не в правильном сообществе нахожусь (сейчас не про Хабр, не подумайте), но набирает популярность factory method pattern.

      С асинхронными конструкторами тоже не встречался

      • mayorovp
        /#24612110

        factory method pattern вообще не про это.


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

        • Crinax
          /#24612174

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

  5. Cryvage
    /#24612098 / +5

    Зачем так сложно? Можно безо всяких декораторов.

    class Fahrenheit {
      constructor(value, key) {
        if (Fahrenheit.#key !== key) throw new Error("Using of raw constroctor is not allowed. Use fromCelsius method instead.");
        this.#value = value;
      }
      
      static #key = new Object();
      #value;
      
      static fromCelsius(value) {
        return new Fahrenheit(value * 9/5 + 32, Fahrenheit.#key);
      }
    }
    

    А вообще, пример, конечно, притянут за уши. Обычно, приватные конструкторы нужны для реализации синглтонов. Но в JS можно реализовать синглтон и без этого.
    Например, вот так
    class Singleton {
      constructor(val) {
        if (Singleton.#instance != null) return Singleton.#instance;
        Singleton.#instance = this;
        this.Val = val;
      }
    
      Val;
      
      static #instance;
    }
    

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

    На вскидку, сложно сказать, насколько реально нужен приватный конструктор в JS, но он уж точно не является приоритетом. Вот чего не хватает, так это модификатора protected. Уж если начали модификаторы доступа добавлять, не стоило останавливаться на private.

  6. Mecitan
    /#24612412 / +1

    Испытал диссонас. JS сообщество идёт по пути функционального программирования, а новый стандарт дал угла в сторону ООП или я чего-то не понимаю?! Разъясните для новичка пожалуйста.

    • mayorovp
      /#24612494 / +1

      Во-первых, когда стандарт принимали, так сильно сообщество в сторону ФП ещё не шло.
      Во-вторых, что плохого в мультипарадигменности языка программирования?
      В-третьих, куча функций — ещё не ФП, и сообщество зашло на путь ФП не так далеко как многие думают.

      • Mecitan
        /#24615668

        1. Это только плюс, когда есть такие возможности. Я с этим солидарен. Однако бытует мнение, что на классах писать не солидно. Особенно это касается React-a. Тип, мол так сообщество не пишет. Хотя моё видение на этот счёт, что надо использовать все возможности языка. Пусть даже код будет чуть длиннее, но зато более читаемый. (речь о классах).

        2. Много читал про это. По этому просто выражу свою благодарность за ваш ответ.

        • Crinax
          /#24615732

          По поводу классовых компонентов. У них есть одна проблема, которая описана в этой статье. Сейчас проверил - она до сих пор актуальна.

          • mayorovp
            /#24615876 / +1

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

          • Alexandroppolus
            /#24616054

            Проблема классовых компонентов только одна - они хуже декомпозируются и имеют склонность превращаться в год-обжекты того или иного размера, с довольно низким cohesion. Да и реактовская центральная идея "ui как значение от стейта" более естественно вписывается в функции.

            Но, как уже говорил, это относится только к компонентам. Писать логику может быть удобнее в духе ООП (хотя тут субъективно).

            • nin-jin
              /#24616346

              И что же тут сложного в декомпозиции классов?

  7. ALexhha
    /#24613172 / +2

    Ну как тут не вспомнить