Классы

120

В языке JavaScript класс - это множество объектов, наследующих свойства от общего объекта-прототипа. Таким образом, объект-прототип является центральной особенностью класса.

В предыдущей статье была определена функция inherit(), возвращающая вновь созданный объект, наследующий указанный объект-прототип. Если определить объект-прототип и затем воспользоваться функцией inherit() для создания объектов, наследующих его, фактически будет создан класс JavaScript.

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

// range - класс, представляющий диапазон значений.
// Это фабричная функция, которая возвращает новый объект range
function range(from, to) {
	// Использует функцию inherit() для создания объекта, 
	// наследующего объект-прототип, определяемый ниже. 
	// Объект-прототип хранится как свойство данной функции 
	// и определяет общие методы (поведение) для всех объектов range
	var r = inherit(rangeObj);
	
	// Сохранить начальное и конечное значения в новом объекте range.
	// Это не унаследованные свойства, и они являются уникальными для данного объекта.
	r.from = from;
	r.to = to;
	
	// В заключение вернуть новый объект 
	return r;
}

// Ниже следует объект-прототип, определяющий методы, 
// наследуемые всеми объектами range
var rangeObj = {
	// Возвращает true, если x объект класса range, 
	// в противном случае возвращает false. 
	// Этот метод может работать не только с числовыми диапазонами, 
	// но также с текстовыми диапазонами и с диапазонами дат Date.
	includes: function(x) { 
		return this.from <= x && x <= this.to; },
		
	// Вызывает func для каждого целого числа в диапазоне.
	// Этот метод может работать только с числовыми диапазонами.
	foreach: function(func, objectToWrite) {
		for(var x = Math.ceil(this.from); x <= this.to; x++) 
			func.call(objectToWrite, x);
	},
	
	// Возвращает строковое представление диапазона
	toString: function() { 
		return "Диапазон (" + this.from + "..." + this.to + ")"; 
	}
};

// Ниже приводится пример использования объекта range
var r = range(1,3);				// Создать новый объект range
console.log(r.includes(2));			// true: число 2 входит в диапазон
r.foreach(console.log, console);		// Выведет 1 2 3 
console.log(r.toString());			// Выведет 'Диапазон (1...3)'

В этом примере есть несколько интересных моментов, которые следует отметить особо. Здесь определяется фабричная функция range(), которая используется для создания новых объектов range.

Отметьте, что функция range() определяет свойства from и to для каждого объекта range. Эти не общие, не унаследованные свойства определяют уникальную информацию для каждого отдельного объекта range. Наконец, обратите внимание, что все общие, унаследованные методы от rangeObj используют свойства from и to и ссылаются на них с помощью ключевого слова this, указывающего на объект, относительно которого вызываются эти методы. Такой способ использования this является фундаментальной характеристикой методов любого класса.

Конструкторы

В приведенном выше примере демонстрируется один из способов определения класса в языке JavaScript. Однако это не самый типичный способ, потому что он не связан с определением конструктора. Конструктор - это функция, предназначенная для инициализации вновь созданных объектов.

Конструкторы вызываются с помощью ключевого слова new. Применение ключевого слова new при вызове конструктора автоматически создает новый объект, поэтому конструктору остается только инициализировать свойства этого нового объекта. Важной особенностью вызова конструктора является использование свойства prototype конструктора в качестве прототипа нового объекта. Это означает, что все объекты, созданные с помощью одного конструктора, наследуют один и тот же объект-прототип и, соответственно, являются членами одного и того же класса.

В следующем примере демонстрируется, как можно было бы реализовать класс Range, представленный ранее, не с помощью фабричной функции, а с помощью функции конструктора:

// Еще один класс, представляющий диапазон значений.
// Это функция-конструктор, которая инициализирует новые объекты Range.
// Обратите внимание, что она не создает и не возвращает объект.
// Она лишь инициализирует его.
function Range(from, to) {
	// Сохранить начальное и конечное значения в новом объекте Range.
	// Это не унаследованные свойства, и они
	// являются уникальными для данного объекта.
	this.from = from;
	this.to = to;
}

// Все объекты Range наследуют свойства этого объекта.
// Обратите внимание, что свойство обязательно должно иметь имя "prototype"
Range.prototype = {
	includes: function(x) { 
		return this.from <= x && x <= this.to; },

	foreach: function(func, objectToWrite) {
		for(var x = Math.ceil(this.from); x <= this.to; x++) 
			func.call(objectToWrite, x);
	},

	toString: function() { 
		return "Диапазон (" + this.from + "..." + this.to + ")"; 
	}
};

// Ниже приводится пример использования объекта Range
var r = new Range(1,3);			// Создать новый объект Range
console.log(r.includes(2));			// true: число 2 входит в диапазон
r.foreach(console.log, console);		// Выведет 1 2 3 
console.log(r.toString());			// Выведет 'Диапазон (1...3)'

Имеет смысл детально сравнить эти два примера и отметить различия между этими двумя способами определения классов. Во-первых, обратите внимание, что при преобразовании в конструктор фабричная функция range() была переименована в Range(). Это обычное соглашение по оформлению: функции-конструкторы в некотором смысле определяют классы, а имена классов начинаются с заглавных символов. Имена обычных функций и методов начинаются со строчных символов.

Далее отметьте, что конструктор Range() вызывается (в конце примера) с ключевым словом new, тогда как фабричная функция range() вызывается без него. В первом примере для создания нового объекта использовался вызов обычной функции, а во втором - вызов конструктора. Поскольку конструктор Range() вызывается с ключевым словом new, отпадает необходимость вызывать функцию inherit() или предпринимать какие-либо другие действия по созданию нового объекта. Новый объект создается автоматически перед вызовом конструктора и доступен в конструкторе как значение this. Конструктору Range() остается лишь инициализировать его.

Конструкторы даже не должны возвращать вновь созданный объект. Выражение вызова конструктора автоматически создает новый объект, вызывает конструктор как метод этого объекта и возвращает объект. Тот факт, что вызов конструктора настолько отличается от вызова обычной функции, является еще одной причиной, почему конструкторам принято давать имена, начинающиеся с заглавного символа. Конструкторы предназначены для вызова в виде конструкторов, с ключевым словом new, и обычно при вызове в виде обычных функций они не способны корректно выполнять свою работу. Соглашение по именованию конструкторов, обеспечивающее визуальное отличие имен конструкторов от имен обычных функций, помогает программистам не забывать использовать ключевое слово new.

Еще одно важное отличие заключается в способе именования объекта-прототипа. В первом примере прототипом был объект rangeObj. Это было удобное, описательное имя, но в значительной мере произвольное. Во втором примере прототипом является свойство Range.prototype, и это имя является обязательным. Выражение вызова конструктора Range() автоматически использует свойство Range.prototype как прототип нового объекта Range.

Наконец, обратите также внимание на одинаковые фрагменты примеров - в обоих классах методы объекта range определяются и вызываются одинаковым способом.

Свойство constructor

Во втором примере свойству Range.prototype присваивался новый объект, содержащий методы класса. Хотя было удобно определить методы как свойства единственного объекта-литерала, но при этом совершенно не было необходимости создавать новый объект.

Роль конструктора в языке JavaScript может играть любая функция, поскольку выражению вызова конструктора необходимо лишь свойство prototype. Следовательно, любая функция (кроме функций, возвращаемых методом Function.bind() в ECMAScript 5) автоматически получает свойство prototype. Значением этого свойства является объект, который имеет единственное неперечислимое свойство constructor. Значением свойства constructor является объект функции:

var F = function() {};		// Это объект функции
var p = F.prototype;			// Это объект-прототип, связанный с ней
var c = p.constructor;		// Это функция, связанная с прототипом, т.е. F()
c === F					// true: F.prototype.constructor === F для всех функций

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

var obj = new F();			// Создать объект класса F
obj.constructor === F;		// true: свойство constructor определяет класс

Эти взаимосвязи между функцией-конструктором, ее прототипом, обратной ссылкой из прототипа на конструктор и экземплярами, созданными с помощью конструктора, иллюстрируются следующей диаграммой:

Функция-конструктор, ее прототип и экземпляры

Обратите внимание, что в качестве примера для этой диаграммы был взят наш конструктор Range(). Однако в действительности класс Range замещает предопределенный объект Range.prototype своим собственным. А новый объект-прототип не имеет свойства constructor. По этой причине экземпляры класса Range, как следует из определения, не имеют свойства constructor. Решить эту проблему можно, явно добавив конструктор в прототип:

Range.prototype = {
	constructor: Range,
	
	includes: function(x) 
       ...
}

Другой типичный способ заключается в том, чтобы использовать предопределенный объект-прототип, который уже имеет свойство constructor, и добавлять методы в него:

Range.prototype.includes = function(x) { 
		return this.from <= x && x <= this.to; };

Range.prototype.foreach = function(func, objectToWrite) {
		for(var x = Math.ceil(this.from); x <= this.to; x++) 
			func.call(objectToWrite, x);
	};

Range.prototype.toString = function() { 
		return "Диапазон (" + this.from + "..." + this.to + ")"; 
	};

Процесс определения класса в языке JavaScript можно свести к трем этапам. Во-первых, написать функцию-конструктор, которая будет определять свойства экземпляра в новом объекте. Во-вторых, определить методы экземпляров в объекте-прототипе конструктора. В-третьих, определить свойства класса в самом конструкторе.

Пройди тесты
Лучший чат для C# программистов