Прототипы JavaScript — программистам на C/C++/C#/Java

  • Tutorial
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("Person's age is %d\n", computeAge(p, 2013));
    

  • C++

    Person *p = new Person;
    p->setLastName("Poupkine");
    printf("Person's age is %d\n", 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++:

  1. Пространства имён (namespaces). В варианте на C мы определили функцию computeAge. Эта функция находится в глобальном пространстве имён: она «видна» всей программе. В другом месте теперь создать такую функцию не получится. А что если мы сделали новый вид объектов, скажем, Pony, и хотим сделать подобный метод, вычисляющий возраст пони? Нам понадобится не только создать новый метод ponyComputeAge(), но и переименовать старый метод, чтобы добиться единообразия: personComputeAge(). В общем, мы «захламляем» пространство имён, делая с течением времени создание новых видов объектов всё более сложным. Если же мы помещаем функцию computeAge() в класс, как в C++, у нас может быть много подобных функций в разных классах. Они не будут мешать друг другу.

  2. Сокрытие информации (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);
    }
    

  3. Создание интерфейса. В варианте на 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));

image

Прототипы


Получившаяся функция 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

image

Мы видим, что если указать, что прототипом объекта 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!), то происходят две вещи:

  1. Создаётся новый пустой объект {}, и 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() {  };
    console.log({ "a": "this is an object", "f": f }.f());
    console.log(new f());
    

  2. Кроме того, у каждой функции есть специальный атрибут .prototype. Объект, на который показывает атрибут .prototype, автоматически станет прототипом вновь созданного объекта из пункта 1.

    Попробуйте в node:

    var fooProto = { "foo": "prototype!" };
    var f = function() { };
    (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;
}

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;

В принципе, можно не менять целиком объект, на который указывает 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;
}

Эта ручная инициализация атрибутов объекта (firstName, lastName) в значения аргументов (first, last) подходит для вариантов с совсем небольшим количеством аргументов. Но для больших и развесистых конфигураций ручное перечисление атрибутов становится неудобным и излишне многословным.

Мы можем упростить инициализацию объекта с множеством полей с помощью функции jQuery.extend, которая просто копирует атрибуты из одного объекта в другой:

function createPerson(first, last, born) {
    var person = { firstName:   first,
                   lastName:    last,
                   yearOfBirth: born });
   $.extend(this, person);
}

Кроме того, мы можем и не передавать кучу полей аргументами функции, а передавать на вход функции объект с уже нужными нам полями:

function createPerson(person) {
   $.extend(this, person);
}

var p = new createPerson({ firstName: "Anne",
                           lastName: "Hathaway",
                           yearOfBirth: 1982 });
console.log(p);

(К сожалению, из-за необходимости использовать jQuery, этот код проще всего пробовать уже в браузере, а не в терминале с node.)

Этот код уже выглядит просто и компактно. Но почему мы создаём «новый createPerson»? Пора переименовать метод в более подходящее имя:

function Person(person) {
   $.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:

image

Такая форма записи уже очень похожа на то, как записывается и работает класс в C++, поэтому в JavaScript функцию Person иногда называют классом. Например, можно сказать: «у класса Person есть метод computeAge».

Ссылка


Поделиться публикацией

Похожие публикации

Комментарии 30

    +9
    Спасибо за ежемесячную статью о прототипах в JavaScript!!1
      +2
      Да ладно, от кода из этой статьи волосы на подмышках дыбом встают. Ни одного упоминания даже о Object.create, а obj.__proto__=blahblahblah хватает, конструкторы на return this заканчиваются…
        0
        Спасибо за критику :)

        Я решил не использовать в объяснении механики прототипов то, чего нельзя использовать в IE6. Даже __proto__ использовал только вскользь.

        Впрочем, я ожидал, что критика коснётся в основном того, что используется printf() в C++, или какой-нибудь подобной ерунды. Хорошо, что она началась с действительно фундаментальных недостатков статьи, таких как Object.create.
          0
          Я решил не использовать в объяснении механики прототипов
          var fooProto = { "foo": "prototype!" };
          var f = function() { };
          f.prototype = fooProto;

          А теперь злой фокус, который может ожидать новичков после прочтения вашей статьи:

          var object = new f;
          
          object.constructor.prototype.bar = 'FUCK!'; 
          console.log(object.foo, 'Object.prototype'.bar); // prototype!, FUCK!
          
            0
            Если бы это был фокус, который действительно ждёт новичков после прочтения моей статьи, то это было бы здорово.

            Первая проблема, которая действительно появляется у новичков — это почему нужно писать Person.prototype.foo вместо Person.foo. Не переоценивайте новичков.
            0
            Даже __proto__ использовал только вскользь.

            Его лучше вообще не использовать, эта фишка не стандартизирована, сегодня есть — завтра нет: mdn
              0
              Использовать его можно, но «с головой».
              В ES6 должны его включить: B.3.1.1 Object.prototype.__proto__
            0
            Да ладно

            У меня ведь был очевидный сарказм в комментарии)
              0
              Это да, но острых ощущений от беглого просмотра кода не отменяет:) Например, того же return this в конструкторах я не припомню.
                0
                Убрал, чтобы не никого не вводить в заблуждение.
            –4
            Зашел сюда почитать этот коммент
              0
              И как обычно тема не расскрыта.

              Ни слова о полиморфизме, наследовании ES5, и пр.

              На вскидку автору:

              var foo = function() {
                 this.hellow = 'Hellow ';
              };
              
              var bar = function() {
                 foo.apply(this);
                 this.world = 'World!'
              };
              
              var object = new bar;
              
              alert(object.hellow + object.world) // 'Hellow World!'
              
                +1
                Чтобы раскрыть тему до уровня, произвольно назначенного комментатором хабрахабра, надо написать книгу. Такой задачи не стояло. Стояла задача показать механизм прототипов и позволить сформироваться соответствующей интуиции. Дело в том, что это учебный материал, а не справочный. Если учебный материал сделать чрезвычайно точным и полным описанием реальности, студенты отваливаются и материал перестаёт справляться со своей задачей. Приходится искать баланс.

                Что касается .apply и .call — это немного другая тема, отличная от темы прототипов в JS. Это тема функциональных контекстов в JS, и достойна другой статьи, которая показывает на примерах механику этого процесса. Если сделать такую статью качественной и полной, она будет едва ли не в половину от данной. Теперь представим, что нам нужно объяснить одновременно и .apply/.call, и прототипы. Стоит ли нам соединять эти две длинные статьи вместе, в одну большую портянку, говорящую обо всём? Я считаю, что не стоит. Те, кто считает, что стоит, пишут книги по 1000+ страниц, разжёвывающие каждую тему.

                Короче, кроме корректности и полноты у текста есть характеристика «может ли он научить». Сделать одновременно корректный, полный и обучающий текст, да так, чтобы не расплескать по дороге учеников — это адская задача.
            • НЛО прилетело и опубликовало эту надпись здесь
                +1
                Было бы куда интереснее рассмотреть тему приватных полей и использования трюков с замыканиями и call.
                  +1
                  Напишу, а первый комментарий будет «Опять эти ежемесячные введения в замыкания от хаскелистов» :)

                  А если серьёзно, то что именно стоит рассмотреть? call/apply? Ведь есть на хабре такие статьи, какой именно подачи не хватает?
                  0
                  С JavaScript никогда дела не имел, поэтому с интересом статью прочитал. Написано неплохо для обзорного понимания, спасибо.
                  • НЛО прилетело и опубликовало эту надпись здесь
                      0
                      У меня в коде единственной «ошибкой» можно считать отсутствие инициализации объекта. То есть, использование malloc() вместо calloc(), что порождает объект, инициализированный мусором. Это было сделано сознательно, в иллюстративных целях. Потому что если C++ и Java программисты ещё помнят про malloc() в C, то про calloc() помнят уже меньше.

                      Если мысленно заменить «malloc(» на «calloc(1,», код мгновенно становится рабочим и корректным. Но у студентов по мере прочтения появляются вопросы к периферийной, неосновной части текста, что затрудняет обучение JavaScript'у.

                      Что касается непрозрачных объектов в C — да, подобное скрытие является стандартной практикой.
                      • НЛО прилетело и опубликовало эту надпись здесь
                      0
                      Меня вот что удивляет: почему классы до сих пор не внесены в стандарт ECMAScript? Видно же, что каждый JS-программист, создающий что-то сложнее HelloWorld'а, просто вынужден написать собственную реализацию классов через прототипы (потому что без классов как без рук, а чисто прототипный код быстро превращается в лапшу, полностью теряя структурность уже на 50000 строчек).
                      Но нет, они упорно твердят, что классы «нинужны», ибо есть ведь миллион кривых реализаций через прототипы.
                      Просто классоненавистничество какое-то…
                      Ввели бы в стандарт классы и возможность явной типизации, и сколько сил сэкономили бы разработчикам!
                        0
                        просто вынужден написать собственную реализацию классов через прототипы (потому что без классов как без рук, а чисто прототипный код быстро превращается в лапшу, полностью теряя структурность уже на 50000 строчек).

                        Вы не правы. Прототипы в JS плохи не потому что «не как классы в Java», а потому что многословны. Многословано делать наследование и добавлять методы, потому приходится это оборачивать в функцию. Необходим только лёгкий сахар для того, чтобы есть.
                          0
                          Ну, не только. Там совершенно не хватает элементарных базовых вещей. Инкапсуляции — нет, интерфейсов — нет, перегрузок — в нормальном виде тоже нет. Можно продолжать еще долго.
                            0
                            Инкапсуляция — это не модификатор «private», это сокрытие реализации под интерфейсом.
                            Перегрузки в таком виде, как в Java и не нужны, как и интерфейсы
                              0
                              Ну, я про прайват и не говорил, если что (хотя и его тоже нет). А инкапсуляция в общепринятом смысле это вот. И как ее надежно реализовать без сокрытия данных я лично плохо представляю.
                              Мне, например еще много чего там не хватает: отсутствие контрактов, условная компиляция — те же ассерты (я в курсе как работает JS, но все равно не хватает :)) и еще куча всего. Обиднее всего то, что язык дает другие очень мощные возможности, связанные с динамической типизацией и прочими динамическими фокусами, но использовать эти возможности где-то, кроме библиотек, никто в здравом уме не станет.
                          +1
                          +1
                          Javascript целенаправленно не учил, по-этому с интересом прочитал статью. Спасибо!
                            +1
                            … В общем, мы «захламляем» пространство имён, делая с течением времени создание новых видов объектов всё более сложным

                            ммм, а как же модификатор static…
                              +1
                              Если на функции навесить модификатор static в модуле (не в заголовке), то они перестанут быть доступны снаружи модуля. То есть, методами объекта станет невозможно пользоваться. Это очевидным образом разрушает идею объекта как полезной сущности.
                              0
                              Спасибо, очень доступное объяснение. Статья заслуживает больше плюсов!

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

                              Самое читаемое