Skip to content

Latest commit

 

History

History
241 lines (170 loc) · 21.4 KB

File metadata and controls

241 lines (170 loc) · 21.4 KB

Вы не знаете JS: This и прототипы объектов

Приложение А: class в ES6

Если что-то можно вынести из второй половины этой книги (Главы 4-6), так это то, что классы - необязательный паттерн проектирования кода (не является необходимым). Более того, зачастую, их неудобно реализовывать в «прототипном» языке вроде JS.

Это неудобство связано не только с синтаксисом, хоть он и играет значительную роль. В главах 4 и 5 мы рассмотрели некоторые синтаксические уродства: от многословных ссылок .prototype, загромождающих код, до явного псевдо-полиморфизма (см. главу 4), когда вы называете метод одним и тем же именем на разных уровнях цепочки и пытаетесь реализовать полиморфные отсылки из методов низкого уровня к методам высокого уровня. Еще одно синтаксическое уродство - .constructor, который ошибочно интерпретируется как «был сконструирован с помощью» и тем не менее ненадёжен из-за такого определения.

Но проблема с дизайном классов намного глубже. Глава 4 указывает, что классы в традиционных класс-ориентированных языках на самом деле выполняют копирование от родительского к дочернему экземпляру, в то время как в [[Prototype]] выполняется не копирование, а скорее наоборот — связывающее делегирование.

По сравнению с простотой кода в OLOO-стиле и делегированием поведения (см. главу 6), которые принимает [[Prototype]], а не прячет, классы торчат из JS как сломанный палец.

class

Но нам не нужно повторять всё это. Я кратко упомянул эти проблемы только чтобы вы держали их в голове пока мы переключаем всё внимание на механизм class в ES6. Здесь мы покажем как они работают и посмотрим дает ли class что-то существенное для решения всех этих «классовых» проблем.

Давайте вспомним пример Widget / Button из главы 6:

class Widget {
	constructor(width,height) {
		this.width = width || 50;
		this.height = height || 50;
		this.$elem = null;
	}
	render($where){
		if (this.$elem) {
			this.$elem.css( {
				width: this.width + "px",
				height: this.height + "px"
			} ).appendTo( $where );
		}
	}
}

class Button extends Widget {
	constructor(width,height,label) {
		super( width, height );
		this.label = label || "Default";
		this.$elem = $( "<button>" ).text( this.label );
	}
	render($where) {
		super.render( $where );
		this.$elem.click( this.onClick.bind( this ) );
	}
	onClick(evt) {
		console.log( "Button '" + this.label + "' clicked!" );
	}
}

Кроме того, что синтаксис выглядит приятнее, какую проблему решает ES6?

  1. Больше нет (ну, типа того, см. ниже) отсылок к .prototype, загромождающих код.
  2. Button объявляется напрямую чтобы «унаследовать» (extends) Widget, вместо использования Object.create(..) для замены привязанного .prototype или установки через .__proto__ или Object.setPrototypeOf(..).
  3. super(..) теперь дает нам полезную функцию — относительный полиморфизм, так что любой метод одного уровня цепочки может обратиться к методу с тем же именем на другой уровень выше по цепочке. Сюда входит решение к заметке из главы 4 о странностях конструкторов, не принадлежащих к их классу и не связанных друг с другом — super() работает внутри конструкторов именно так как вы ожидаете.
  4. Литеральный синтаксис class не позволяет указать свойства (только методы). Для кого-то это покажется ограничением, но, скорее всего, большинство ситуаций, в которых свойство (состояние) существует где-либо, кроме конечных экземпляров, являются ошибочными и неожиданными (поскольку это состояние, которе явно «распространяется» по всем «экземплярам»). В общем, можно сказать, что синтаксис class защищает вас от ошибок.
  5. extends позволяет вам расширить даже встроенные (под)типы объектов, вроде Array или RegExp очень естественным способом. Такие действия без class .. extends долгое время оставались избыточно сложной и удручающей задачей.

Справедливости ради, это лишь некоторые из важных решений множества наиболее очевидных (синтаксических) проблем и сюрпризов, которые встречают люди с кодом в классическом прототипном стиле.

Глюки class

Но не всё так радужно. До сих пор существуют некоторые глубокие и тревожащие проблемы с использованием «классов» в качестве паттерна проектирования на JS.

Во-первых, синтаксис class может убедить вас, что в JS существует новый механизм «классов» ES6. Нет. class — это, в основном, синтаксический сахар поверх существующего механизма (делегирования!) [[Prototype]].

Это означает, что class на самом деле не копирует содержимое статически во время объявления, как это происходит в традиционных класс-ориентированных языках. Если вы измените/замените метод (намеренно или случайно) родительского «класса», дочерний «класс» и/или экземпляр тоже будет затронут, поскольку они не копируются при объявлении, а всё еще используют модель делегирования, основанного на [[Prototype]]:

class C {
	constructor() {
		this.num = Math.random();
	}
	rand() {
		console.log( "Random: " + this.num );
	}
}

var c1 = new C();
c1.rand(); // "Random: 0.4324299..."

C.prototype.rand = function() {
	console.log( "Random: " + Math.round( this.num * 1000 ));
};

var c2 = new C();
c2.rand(); // "Random: 867"

c1.rand(); // "Random: 432" -- Ой!!!

Такое поведение только кажется разумным если вы уже знаете о делегирующей природе вещей и не ожидаете копий из «настоящих классов». Поэтому задайте себе вопрос: почему вы выбираете синтаксис class для чего-то фундаментально отличающегося от классов?

Может быть синтаксис class в ES6 просто мешает увидеть и понять разницу между традиционными классами и делегированными объектами?

Синтаксис class не предоставляет способа объявить свойства экземпляра класса (только методы). Поэтому если вам нужно это для отслеживания состояния между экземплярами, вы вернётесь обратно к некрасивому синтаксису .prototype, вроде такого:

class C {
	constructor() {
		// убедитесь, что изменяете общее состояние,
		// а не добавляете затеняющее свойство
		// к экземплярам!
		C.prototype.count++;

		// Здесь, `this.count` работает как и ожидается
		// через делегирование
		console.log( "Hello: " + this.count );
	}
}

// добавим свойство для общего состояния напрямую
// к объекту-прототипу
C.prototype.count = 0;

var c1 = new C();
// Hello: 1

var c2 = new C();
// Hello: 2

c1.count === 2; // true
c1.count === c2.count; // true

Самая большая проблема здесь в том, что он предаёт синтаксис class, выставляя (утечка!) .prototype как часть реализации.

Но у нас всё еще остался неожиданный глюк, когда this.count++ неявно создает затеняющее свойство .count в обоих объектах c1 и c2, вместо того, чтобы обновить общее состояние. class не предлагает нам решения этой проблемы, кроме, кажется, предположения, что вы не должны так делать вообще, в виду слабой поддержки синтаксиса.

Более того, случайное затенение всё еще представляет угрозу:

class C {
	constructor(id) {
		// Ой, блин, мы затеняем метод `id()`
		// значением свойства в экземпляре
		this.id = id;
	}
	id() {
		console.log( "Id: " + this.id );
	}
}

var c1 = new C( "c1" );
c1.id(); // TypeError -- `c1.id` стала строкой "c1"

Существует еще один тонкий нюанс, связанный с работой super. Вы могли предположить, что super будет привязан по аналогии с привязкой this (см. главу 2), что super всегда будет привязан на уровень выше, вне зависимости от текущего положения метода в цепочке [[Prototype]].

Тем не менее, в целях повышения производительности (привязка this и так дорого стоит), super не привязывается динамически. Его привязка вроде как «статичная», как и момент вызова. Не так уж страшно, верно?

Эх... может быть, а может и нет. Если вы как и большинство разработчиков на JS начинаете назначать функции различным объектам (что следует из определения class) различными способами, вас, возможно, не очень обеспокоит, что под капотом механизм super вынужден каждый раз привязывать себя заново.

И в зависимости выбранного синтаксического подхода к присваиванию возможны случаи, когда super не может быть корректно привязан (по крайней мере не там, где вы ожидаете), поэтому вам может понадобиться (на момент написания обсуждение TC39 продолжается) привязать super вручную через toMethod(..) (наподобие того как вы делаете bind(..) для this -- см. главу 2).

Вы привыкли к возможности присваивать методы различным объектам чтобы автоматически получать выгоду от динамизма this через скрытое привязывание (см. главу 2). Но, похоже, всё это не сработает для методов, использующих super.

Рассмотрим что super должен делать здесь (напротив D и E):

class P {
	foo() { console.log( "P.foo" ); }
}

class C extends P {
	foo() {
		super();
	}
}

var c1 = new C();
c1.foo(); // "P.foo"

var D = {
	foo: function() { console.log( "D.foo" ); }
};

var E = {
	foo: C.prototype.foo
};

// Ссылка от E к D для делегирования
Object.setPrototypeOf( E, D );

E.foo(); // "P.foo"

Если вы думали (вполне обоснованно!), что super будет привязан динамически во время вызова, вы могли ожидать, что super автоматически распознает, что E делегирует к D, поэтому E.foo(), используя super(), должен вызвать D.foo().

Нет. В целях производительности, super не использует отложенную привязку (динамическую привязку) как это делает this. На самом деле он получен во время вызова из [[HomeObject]].[[Prototype]], где [[HomeObject]] статично привязан в момент создания.

В данном конкретном примере super() всё еще разрешается в P.foo(), поскольку [[HomeObject]] этого метода всё еще C, а C.[[Prototype]] является P.

Возможно, найдутся и пути решения таких проблем. Использование toMethod(..) чтобы привязать/перепривязать [[HomeObject]] для метода (вместе с заданием [[Prototype]] этого объекта!), кажется, сработает:

var D = {
	foo: function() { console.log( "D.foo" ); }
};

// Привязать E к D для делегирования
var E = Object.create( D );

// вручную связать `[[HomeObject]]` из `foo` в виде
// `E`, а `E.[[Prototype]]` — это `D`, так что
// `super()` — это `D.foo()`
E.foo = C.prototype.foo.toMethod( E, "foo" );

E.foo(); // "D.foo"

Примечание: toMethod(..) клонирует метод и принимает homeObject в качестве первого параметра (поэтому мы передаём E), а второй параметр (необязательный) задаёт name для нового метода (который хранится в «foo»).

Осталось увидеть нет ли глюков в других крайних случаях, которые разрабы встретят за пределами описанного сценария. Как бы то ни было, вам нужно быть начеку и замечать места, где движок автоматически разбирается с super за вас, и места, где вам нужно самим об этом позаботиться. Ох!

Статический > Динамический?

Но самая большая проблема class ES6 в том, что все эти глюки означают, что class как бы навязывает вам синтаксис, который, якобы, подразумевает (как традиционные классы), что однажды объявленный class является статическим определением чего-либо (наследуемого в будущем). Вы полностью теряете осознание факта, что C — это объект, конкретная вещь, с который вы можете взаимодействовать напрямую.

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

class вроде подразумевает, что вы не должны делать такие штуки, склоняя вас использовать уродливый синтаксис .prototype или заставляя вас думать о подвохах super и т.д. Он также предоставляет очень слабую поддержку на случай подводных камней, которые может принести такой динамизм.

Другими словами, class как бы говорит вам: «Динамика — это сильно сложно, так что, возможно, это не лучшая идея». Вот вам синтаксис, который выглядит как статический, так что пишите свой код статически.»

Какой грустный комментарий к JS: динамика слишком сложная, давайте притворимся (но на самом деле не будем) статикой

Это причины, по которым class в ES6 маскируется под красивое решение синтаксической головной боли, но на самом деле еще сильней мутит воду и ухудшает четкость и краткость понимания JS и его особенностей.

Примечание:* Если вы используете инструмент .bind(..), чтобы создать жестко привязанную функцию (см. главу 2), эта функция не может быть наследована с помощью extend из ES6, в отличие от обычных функций.

Обзор (TL;DR)

class очень хорошо притворяется, что решает проблемы с паттерном класс/наследование в JS. Но на самом деле делает обратное: он скрывает многие проблемы, но приносит другие, незаметные, но опасные.

class способствует постоянной путанице с «классами» в JS, которая преследует язык около двух десятков лет. Во многом он вызывает больше вопросов, чем ответов и в целом чувствуется, что он противоестественно расположился над элегантной простотой механизма [[Prototype]].

Итоги: в ES6 class затрудняет надёжное использование [[Prototype]] и скрывает самую важную особенность механизма объектов в JS -- живое делегирование связей между объектами -- не лучше ли рассматривать class как создающий больше проблем, чем решающий и просто отнести его к анти-паттерну?

На самом деле я не могу ответить за вас. Но я надеюсь, что эта книга для вас полностью раскрыла проблему на более глубоком уровне, чем когда-либо ранее и дала вам необходимую информацию чтобы вы сами смогли ответить.