Введение
Месяц назад вышел новый стандарт 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)
k12th
09.08.2022 10:00+2А как все-таки связаны приватные и асинхронные конструкторы?
Crinax Автор
09.08.2022 10:19Если я где-то упоминал про асинхронные конструкторы, то процитируйте, пожалуйста, а-то, вроде, всю статью перечитал - не нашёл :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
09.08.2022 10:37+3Ага, значит тут мы получаем данные платежа с сервера и создаём на их основе объект.
А что если мы получили данные платежа из другого источника? Прочитали из файла? Забили константами, потому что у нас модульный тест? В чём проблема обратиться к конструктору когда у нас есть данные платежа?
nin-jin
09.08.2022 12:03-2Приведу вам пример из жизни. Вот есть у вас Uint8Array с данными криптографического ключа. Вам нужно создать экземпляр обёртки:
const key = new Key( data )
И.. это не компилируется, так как функция импорта ключа - асинхронная. И приходится менять конструктор на фабрику:
const key = await Key.from( data )
mayorovp
09.08.2022 12:12Функция импорта ключа возвращает CryptoKey. И, скорее всего, именно этот CryptoKey и будет передан в конструктор.
Внимание, вопрос: есть ли какая-то действительно важная причина запрещать прямой вызов конструктора, если у вызывающей стороны уже каким-то чудом есть корректный CryptoKey?
nin-jin
09.08.2022 13:06-3Она принимает 100500 параметров. Собственно для того обёртка и нужна, чтобы не возиться с этими функциями напрямую. И подобное костылеварение ещё долго продолжаться будет, пока все методы высшего порядка и операторы не будут продублированы в асинхронном варианте.
mayorovp
09.08.2022 13:18И что дальше? Я всё ещё не понимаю как из 100500 параметров некоторой функции следует необходимость сделать конструктор класса приватным.
nin-jin
09.08.2022 13:22-2Ну а я не вижу причин что либо вообще приватным делать. При чём тут это вооще?
mayorovp
09.08.2022 13:25+1Смотрите, я написал что конструктор не обязательно делать приватным. Вы начали спорить.
Но если вы не видите причин делать что-либо вообще приватным, то о чём вообще спор-то? Какую мысль вы пытаетесь донести?
mayorovp
09.08.2022 10:28+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 Автор
09.08.2022 10:40Звучит на самом деле логично. Я, если честно, сам не встречался с тем, что где-то нужен был приватный конструктор. Может быть я не в правильном сообществе нахожусь (сейчас не про Хабр, не подумайте), но набирает популярность factory method pattern.
С асинхронными конструкторами тоже не встречался
Cryvage
09.08.2022 10:43+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.
Mecitan
09.08.2022 11:53+1Испытал диссонас. JS сообщество идёт по пути функционального программирования, а новый стандарт дал угла в сторону ООП или я чего-то не понимаю?! Разъясните для новичка пожалуйста.
mayorovp
09.08.2022 12:14+1Во-первых, когда стандарт принимали, так сильно сообщество в сторону ФП ещё не шло.
Во-вторых, что плохого в мультипарадигменности языка программирования?
В-третьих, куча функций — ещё не ФП, и сообщество зашло на путь ФП не так далеко как многие думают.Mecitan
10.08.2022 10:45Это только плюс, когда есть такие возможности. Я с этим солидарен. Однако бытует мнение, что на классах писать не солидно. Особенно это касается React-a. Тип, мол так сообщество не пишет. Хотя моё видение на этот счёт, что надо использовать все возможности языка. Пусть даже код будет чуть длиннее, но зато более читаемый. (речь о классах).
Много читал про это. По этому просто выражу свою благодарность за ваш ответ.
Crinax Автор
10.08.2022 11:01По поводу классовых компонентов. У них есть одна проблема, которая описана в этой статье. Сейчас проверил - она до сих пор актуальна.
mayorovp
10.08.2022 11:41+1Не уверен что это именно проблема. Отслеживать устаревающие данные нужно в любом случае, и функциональные компоненты хорошо справляются лишь в простейших случаях. Подробности — в комментариях по вашей же ссылке.
Alexandroppolus
10.08.2022 12:21Проблема классовых компонентов только одна - они хуже декомпозируются и имеют склонность превращаться в год-обжекты того или иного размера, с довольно низким cohesion. Да и реактовская центральная идея "ui как значение от стейта" более естественно вписывается в функции.
Но, как уже говорил, это относится только к компонентам. Писать логику может быть удобнее в духе ООП (хотя тут субъективно).
nin-jin
10.08.2022 12:34Потрясающе. Выдавать проблему работы с устаревшим состоянием за достоинство - это надо уметь.
nin-jin
Вывод от меня: когда вкручивали async/await, как обычно, не нашлось знающего JS человека, чтобы подсказать горе-стандартизаторам, что функции могут вызываться и через `new`, и неплохо бы предусмотреть асинхронность и в этом месте.
Crinax Автор
Если я не ошибаюсь, то асинхронных конструкторов нет и в других языках. По крайней мере поиск не дал результатов на язык C#.
nin-jin
Да и в остальные языки async/await вкручивали люди с тем же уровнем развития. Иначе бы не вкручивали эту чушь вовсе.
Nosferatudima
Есть.
Kolyaj
await же просто ждёт резолва промиса. Возвращайте из конструктора промис, будет у вас асинхронный конструктор.
mayorovp
А теперь попробуйте унаследоваться от этого класса и посмотрите какой this придёт в конструктор наследника...
Впрочем, в других реализациях наследование тоже не особо работает.
nin-jin
mayorovp
Да, это будет работать. Но выглядит ужасно. И IDE почти наверняка соврёт в подсказках.