JavaScript отличается от многих других «объектно-ориентированных» языков программирования тем, что в нём объекты есть, а классов — нет. Вместо классов в JavaScript есть прототипные цепочки и некоторые другие хитрости, которые требуют времени на осмысление. Перед профессиональными программистами в других языках при переходе на JavaScript встаёт проблема быстрого вхождения в его объектную модель.
Этот текст написан для того, чтобы дать начинающему или эпизодическому разработчику на JavaScript представление о способах создания объектов в JavaScript, от простого «структурного», как в языке C, к более «объектно-ориентированному», как в C++/C#/Java.
Статья может быть рекомендована как новичкам в программировании, так и профессиональным бэкенд-программистам, эпизодически вынужденным погружаться в JavaScript.
Объекты и классы в C и C++
Объекты — это сущности, которые обладают идентичностью (identity, возможностью отличить один объект от другого), состоянием (state, аттрибутами, полями), и поведением (behavior, методами, функциями, которые умеют изменять состояние). Для простоты введения в предмет можно представить объекты существующими в памяти только после запуска программы экземплярами соответствующих «классов».
Объекты могут порождаться в течении жизни программы, изменяться, исчезать.
Сравните условный код на C и C++:
- C
struct Person { char *firstName; char *lastName; int yearOfBirth; } // Compute person's age in a given year. void computeAge(struct Person *person, int currentYear); // Set a new last name, possibly deallocating the old one. void setLastName(struct Person *person, char *newLastName);
- C++
class Person { char *firstName; char *lastName; int yearOfBirth; void computeAge(int currentYear); void setLastName(char *newLastName); }
В данных примерах мы на языках C и C++ описали объект Person. Не «создали объект», а «описали его поля и методы», чтобы потом можно было такого рода объекты создавать, и пользоваться ими.
Также посмотрите на соответствующие способы создания одного объекта Person в C и C++:
- C
struct Person *p = malloc(sizeof(*p)); setLastName(p, "Poupkine"); printf("%s's age is %dn", computeAge(p, 2013));
- C++
Person *p = new Person; p->setLastName("Poupkine"); printf("%s's age is %dn", p->computeAge(2013));
Эти две программы делают одно и то же: создают объект и позволяют использовать ассоциированные с ним функции setLastName
, computeAge
(behavior) для изменения или опроса состояния объекта (state). К созданному объекту мы в любой момент можем обратиться через указатель p (identity). Если мы создадим ещё один объект Person *m = new Person
, то мы сможем использовать методы нового объекта, обращаясь к нему через указатель m. Указатели p и m будут указывать на разные объекты, каждый со своим состоянием, хотя и одинаковым набором методов (поведением).
Как мы видим, даже родственные языки C и C++ предлагают немного разные способы описания объекта Person. В одном случае мы описываем объект через структуру данных struct Person
и дружественные функции где-то рядом. В другом случае мы синтаксически помещаем и данные, и функции в один и тот же class Person
.
Почему люди могут предпочитать C++ и «объектно-ориентированный подход», раз мы примерно одно и то же можем делать и в языке C, «без классов», и в языке C++? Есть несколько хороших ответов, которые релевантны в контексте изучения JavaScript, в котором можно использовать как подход C, так и подход C++:
- Пространства имён (namespaces). В варианте на C мы определили функцию computeAge. Эта функция находится в глобальном пространстве имён: она «видна» всей программе. В другом месте теперь создать такую функцию не получится. А что если мы сделали новый вид объектов, скажем, Pony, и хотим сделать подобный метод, вычисляющий возраст пони? Нам понадобится не только создать новый метод ponyComputeAge(), но и переименовать старый метод, чтобы добиться единообразия: personComputeAge(). В общем, мы «захламляем» пространство имён, делая с течением времени создание новых видов объектов всё более сложным. Если же мы помещаем функцию computeAge() в класс, как в C++, у нас может быть много подобных функций в разных классах. Они не будут мешать друг другу.
- Сокрытие информации (information hiding). В варианте на C, кто имеет указатель p на структуру Person, тот может изменить любое поле в объекте. Например, можно сказать p->yearOfBirth++. Так делать — произвольно менять произвольные поля произвольных объектов — считается плохой практикой. Ведь часто нужно не просто менять поле, а согласованно менять несколько полей объекта. А кто это может сделать лучше и корректнее, чем специализированная процедура (метод)? Поэтому стоит иметь возможность запретить менять поля напрямую, и давать их менять только с помощью соответствующих методов. На C это сделать сложновато, поэтому пользуются редко. Но на C++ это сделать элементарно. Достаточно объявить какие-то атрибуты объекта private, и тогда к ним обращаться можно будет только изнутри методов класса:
class Person { // Эти данные могут менять только функции computeAge и setLastName: private: char *firstName; char *lastName; int yearOfBirth; // Эти функции (методы) доступны всем: public: void computeAge(int currentYear); void setLastName(char *newLastName); }
- Создание интерфейса. В варианте на C мы вынуждены для каждого объекта помнить, как для него получить возраст. Для одного объекта мы будем звать
ponyComputeAge()
, для другогоpersonComputeAge()
. В варианте на C++ мы можем просто помнить, что вычисление возраста любого объекта делается черезcomputeAge()
. То есть, мы вводим единый интерфейс для вычисления возраста, и используем его в приложении ко многим объектам. Это удобно.
Объекты и прототипы в JavaScript
Программисты на JavaScript тоже используют преимущества объектного программирования, но в нём нет «классов» как синтаксического способа описания объектов.
Наивный способ
Можно было бы в JavaScript воспользоваться подходом C, когда мы описываем объект через структуру данных и набор функций, работающих над данными:
function createPerson(first, last, born) {
var person = { firstName: first,
lastName: last,
yearOfBirth: born };
return person;
}
function computeAge(p, currentYear) {
return currentYear - p.yearOfBirth;
}
function setLastName(p, newLastName) {
p.lastName = newLastName;
}
// Create a new person and get their age:
var p = createPerson("Anne", "Hathaway", 1982);
console.log(p);
console.log(computeAge(p, 2013));
Попробуйте скопировать весь этот код в программу node
(предварительно установив проект Node.JS) и посмотреть, что она выведет.
Расхламляем пространство имён
Но этот способ обладает теми же недостатками варианта на C, который был указан выше. Давайте попробуем ещё раз, но только в этот раз «засунем» методы setLastName()
и computeAge()
«внутрь» объекта. Этим мы «разгрузим» глобальное пространство имён, не будем его захламлять:
function createPerson(first, last, born) {
var computeAgeMethod = function(p, currentYear) {
return currentYear - p.yearOfBirth;
}
var setLastNameMethod = function(p, newLastName) {
p.lastName = newLastName;
}
var person = { firstName: first,
lastName: last,
yearOfBirth: born,
computeAge: computeAgeMethod,
setLastName: setLastNameMethod
};
return person;
}
// Create a new person and get their age:
var p = createPerson("Anne", "Hathaway", 1982);
// Note the p.computeAge(p) syntax, instead of just computeAge(p).
console.log(p.computeAge(p, 2013));
console.log(p["computeAge"](p, 2013));
Обратите внимание на то, что мы просто перенесли функции извне createPerson
вовнутрь. Тело функции не изменилось. То есть, каждая функция всё ещё ожидает аргумент p
, с которым она будет работать. Способ вызова этих методов практически не изменился: да, нужно вместо вызова глобальной функции computeAge
вызывать метод объекта p.computeAge
, но всё равно функция ожидает p
первым аргументом.
Это довольно избыточно. Воспользуемся следующим трюком: как и в C++, Java и других языках, в JavaScript есть специальное переменная this
. Если функция вызывается сама по себе (f()
), то эта переменная указывает на глобальный объект (в браузере это будет window
). Но если функция вызывается через точку, как метод какого-либо объекта, (p.f()
), то ей в качестве this передаётся указатель на этот самый объект p. Так как мы всё равно будем вынуждены вызывать методы через обращение к соответствующим полям объекта (p.computeAge
), то в методах this
уже будет существовать и выставлен в правильное значение p
. Перепишем код с использованием этого знания. Также попробуйте скопировать его в node
.
function createPerson(first, last, born) {
var computeAgeMethod = function(currentYear) {
return currentYear - this.yearOfBirth;
}
var setLastNameMethod = function(newLastName) {
this.lastName = newLastName;
}
var person = { firstName: first,
lastName: last,
yearOfBirth: born,
computeAge: computeAgeMethod,
setLastName: setLastNameMethod
};
return person;
}
// Create a new person and get their age:
var p = createPerson("Anne", "Hathaway", 1982);
console.log(p.computeAge(2013));
Прототипы
Получившаяся функция createPerson
обладает следущим недостатком: она работает не очень быстро, и тратит много памяти каждый раз при создании объекта. Каждый раз при вызове createPerson
JavaScript конструирует две новых функции, и присваивает их в качестве значений полям «computeAge» и «setLastName».
Как бы сделать так, чтобы не создавать эти функции каждый раз заново? Как сделать так, чтобы в объекте, на который ссылается person, полей computeAge
, и setLastName
не было, но при этом методы person.computeAge()
и person.setLastName()
продолжали работать?
Для решения как раз этой проблемы в JavaScript есть механизм под названием «прототипы», а точнее «цепочки прототипов» (prototype chains). Концепция простая: если у объекта нет собственного метода или поля, то движок JavaScript пытается найти это поле у прототипа. А если поля нет у прототипа, то поле пытаются найти у прототипа прототипа. И так далее. Попробуйте «покрутить» следующий код в Node.JS, скопировав его в node
:
var obj1 = { "a": "aVar" };
var obj2 = { "b": "bVar" };
obj1
obj2
obj2.a
obj2.b
obj2.__proto__ = obj1;
obj1
obj2
obj2.a
obj2.b
Мы видим, что если указать, что прототипом объекта obj2
является объект obj1
, то в obj2
«появляются» свойства объекта obj1
, такие как поле «a» со значением «aVar». При этом печать obj2
не покажет наличие атрибута «a» в объекте.
Поэтому можно методы сделать один раз, положить их в прототип, а createPerson
преобразовать так, чтобы воспользоваться этим прототипом:
function createPerson(first, last, born) {
var person = { firstName: first,
lastName: last,
yearOfBirth: born };
person.__proto__ = personPrototype;
return person;
}
var personPrototype = {
"computeAge": function(currentYear) {
return currentYear - this.yearOfBirth;
}, // обратите внимание на запятую
"setLastName": function(newLastName) {
this.lastName = newLastName;
}
}
// Create a new person and get their age:
var p = createPerson("Anne", "Hathaway", 1982);
console.log(p);
console.log(p.computeAge(2013));
Попробуйте этот код в node
. Обратите внимание, какой простой объект, без собственных методов, показывается через console.log(p)
. И что у этого простого объекта всё равно работает метод computeAge
.
У этого способа задания прототипа объекта есть два недостатка. Первый, что специальный атрибут __proto__
очень новый, и может не поддерживаться браузерами. Второй недостаток таков, что даже перестав захламлять пространство имён функциями computeAge
и setLastName
мы всё равно его загадили именем personPrototype
.
К счастью на выручку приходит ещё один трюк JavaScript, который стандартен и совместим со всеми браузерами.
Если функцию вызвать не просто по имени f()
, а через new f()
(сравните с C++ или Java!), то происходят две вещи:
- Создаётся новый пустой объект {}, и this в теле функции начинает показывать на него.
Подробнее. По умолчанию, при вызове функции
f()
доступный изнутри функцииthis
указывает просто на глобальный контекст; то есть, туда же, куда в браузере показываетwindow
, илиglobal
у Node.JS.var f = function() { console.log(this); }; f() // Выведет в браузере то же, что и строка ниже: console.log(window)
Мы знаем, что если вызывать функцию в качестве поля какого-либо объекта
p.f()
, то this у этой функции будет показывать уже на этот объект p. Но если функцию вызвать черезnew f()
, то будет создан свежий пустой объект{}
, иthis
внутри функции будет показывать уже именно на него. Попробуйте в node:var f = function() { return this; }; console.log({ "a": "this is an object", "f": f }.f()); console.log(new f());
- Кроме того, у каждой функции есть специальный атрибут
.prototype
. Объект, на который показывает атрибут.prototype
, автоматически станет прототипом вновь созданного объекта из пункта 1.Попробуйте в
node
:var fooProto = { "foo": "prototype!" }; var f = function() { return this; }; (new f()).foo // Выведет undefined f.prototype = fooProto; (new f()).foo // Выведет "prototype!"
Обладая этим знанием, легко понять, как написанный выше код createPerson, использующий суперновый атрибут __proto__
, эквивалентен вот этому более традиционному коду:
function createPerson(first, last, born) {
this.firstName = first;
this.lastName = last;
this.yearOfBirth = born;
return this;
}
createPerson.prototype = {
"computeAge": function(currentYear) {
return currentYear - this.yearOfBirth;
}, // обратите внимание на запятую
"setLastName": function(newLastName) {
this.lastName = newLastName;
}
}
// Create a new person and get their age:
var p = new createPerson("Anne", "Hathaway", 1982);
console.log(p);
console.log(p.computeAge(2013));
Обратите внимание на следующие аспекты:
- мы вызываем new createPerson вместо createPerson;
- мы устанавливаем прототипный объект один раз извне функции, чтобы не конструировать функции каждый раз при вызове createPerson;
- мы не забываем возвращать this из createPerson.
В принципе, можно не менять целиком объект, на который указывает createPerson.prototype
, а просто по-отдельности установить ему нужные поля. Эту идиому тоже можно встретить в промышленном JavaScript-коде:
createPerson.prototype.computeAge = function(currentYear) {
return currentYear - this.yearOfBirth;
}
createPerson.prototype.setLastName = function(newLastName) {
this.lastName = newLastName;
}
Подключаем кусочек библиотеки jQuery
Обратите внимание, что тело функции createPerson
вместо простого и понятного
function createPerson(first, last, born) {
var person = { firstName: first,
lastName: last,
yearOfBirth: born };
return person;
}
превратилось в довольно ужасную последовательность манипуляции с this
:
function createPerson(first, last, born) {
this.firstName = first;
this.lastName = last;
this.yearOfBirth = born;
return this;
}
Мы можем исправить эту ситуацию с помощью функции jQuery.extend, которая просто копирует поля из одного объекта в другой:
function createPerson(first, last, born) {
var person = { firstName: first,
lastName: last,
yearOfBirth: born });
return $.extend(this, person);
}
Кроме того, мы можем и не передавать кучу полей аргументами функции, а передавать на вход функции объект с уже нужными нам полями:
function createPerson(person) {
return $.extend(this, person);
}
var p = new createPerson({ firstName: "Anne",
lastName: "Hathaway",
yearOfBirth: 1982 });
console.log(p);
(К сожалению, из-за необходимости использовать jQuery
, этот код проще всего пробовать уже в браузере, а не в терминале с node
.)
Этот код уже выглядит просто и компактно. Но почему мы создаём «новый createPerson»? Пора переименовать метод в более подходящее имя:
function Person(person) {
return $.extend(this, person);
}
Person.prototype.computeAge = function(currentYear) {
return currentYear - this.yearOfBirth;
}
Person.prototype.setLastName = function(newLastName) {
this.lastName = newLastName;
}
var anne = new Person({ firstName: "Anne",
lastName: "Wojcicki",
yearOfBirth: 1973 });
var sergey = new Person({ firstName: "Sergey",
lastName: "Brin",
yearOfBirth: 1973 });
console.log(anne);
console.log(sergey);
Вот как это выглядит в консоли Safari или Chrome:
Такая форма записи уже очень похожа на то, как записывается и работает класс в C++, поэтому в JavaScript функцию Person
иногда называют классом. Например, можно сказать: «у класса Person есть метод computeAge».
Ссылка
Автор: vlm