javascript

Революция в JavaScript. Буквально

  • понедельник, 24 апреля 2017 г. в 03:13:58
https://habrahabr.ru/post/324640/
  • Ненормальное программирование
  • JavaScript


Сегодня 22 апреля юбилейного 2017 года, день рождения человека, без которого не состоялись бы события столетней давности. А значит, есть повод поговорить о революционной истории. Но причём тут Хабр? — Оказалось, всё в этом мире может иметь самую неожиданную связь.

Владимир Ильич Ленин

Как мы знаем, JavaScript может рассматриваться как объектно-ориентированный язык в том смысле, что «всё сводится к объектам» (на самом деле, прототипу объекта). С другой стороны, в широком философском вопросе, мы тоже то и дело имеем дело с объектами и прототипами. Например, при рассмотрении объекта Революции и его прототипа, описанного в «Капитале». Так давайте просто поговорим о тех событиях современным языком!

Со всей революционной прямотой, сразу козыри на стол!
Данная статья является по сути кратким пояснением к механизму наследования в JavaScript, то есть одной из сторон ООП. Передовые пролетарии-разработчики не найдут в ней абсолютно ничего нового. Однако надеюсь, материал может послужить запоминающейся, образной памяткой для широкого круга интересующихся разработкой на JS. Кроме того, возможно, кто-то подтянет свои знания по отечественной истории

Создадим два объекта:

var stalin = {
   gulag: true,
   mustache: true,
   hero: 1
} 
var lenin = {
   baldHead: true,
   armand: false,
   criticalImperialism: true
}

Укажем, что один объект является наследником другого через свойство __proto__ (такая форма записи доступна во всех браузерах, кроме IE10-, и включена в ES2015). Один наследник, а другой — прототип. Проверяем свойства объекта-наследника, там появилось __proto__:

stalin.__proto__ = lenin;
console.log(stalin);

Если свойство не обнаруживается непосредственно в объекте, оно ищется в родительском объекте (прототипе). Попробуем:

console.log(stalin.baldHead);  //  true

Да, свойство доступно, однако его значение нас не устраивает. Перезаписываем его, при этом свойство родительского объекта не меняется:

stalin.baldHead = false;
console.log(lenin.baldHead);  //  true - свойство в прототипе не поменялось

Кстати, а что является прототипом прототипа?

В JS объект, кроме одного случая (об этом ниже) наследует от Object.__proto__ (посмотрите в консоли). В том числе, стандартные методы, доступные по умолчанию: такие, например, как Object.toString(), Object.valueOf() и так далее.

А как нам перечислить свойства непосредственно объекта, без свойств его родителя, чтобы не выполнять лишние операции? – Для этого есть hasOwnProperty:

for (var key in stalin) {
   if (stalin.hasOwnProperty(key)) console.log(key + ": " + stalin[key])
}

Кстати, если объект уже имеет своё свойство, то после присвоения прототипа оно не будет затёрто значением из прототипа, а останется как было:

var dzerjinskiy = {
   mustache: true,
   baldHead: false
}
dzerjinskiy.__proto__ = lenin;
console.log(dzerjinskiy.baldHead);  //  false - при присвоении прототипа осталось тем же

Наконец, может понадобиться простой объект-пустышка без свойств, который нужен только для записи значений. Тогда нам не придётся при перечислении его свойств проверять hasOwnProperty:

var zyuganov = Object.create(null);
zyuganov.experience = 25;
console.log(zyuganov.toString);  //  undefined

При проверке выясняется, что у пустого объекта нет даже стандартных методов, таких, как toString(). Кстати, выше был использован метод Object.create(prototype[, {}]) — метод, позволяющий создать объект с обязательным указанием прототипа (в т.ч. null) и свойствами (не обязательно).

F.prototype и new

Можно создать конструктор, чтобы проще было создавать экземпляры, родителем которых является единственный объект:

var marks = {
   marxism: true,
   engels: "friend",
   beard: 80
} 
function Marksist(name) {
   this._name = name;
   this.__proto__ = marks;
} 
var kamenev = new Marksist("kamenev");
console.log(kamenev);

Видим, что тов. Каменев тоже имеет бороду

Лев Борисович Каменев

Однако что насчёт Революции? Можно добавить в прототип новое значение и метод, тогда и потомок может использовать этот метод:

marks.revolution = "future";
marks.deal = function() {return this.revolution};  //  this ссылается на объект marks

Новое значение появилось в потомке:

console.log(kamenev.revolution);  //  "future"

Мы добавляем свойство или метод в прототип, и оно появляется у потомков без необходимости их переопределения. Сила прототипного наследования!

Естественно, значение в потомке можно модифицировать, остальных потомков прототипа это не коснётся:

kamenev.revolution = "now";
console.log(kamenev.deal());  //  "now"

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

Для поддержки во всех браузерах, в т.ч. старых, есть другой способ:

function Marksist(name) {
   this._name = name;			
}
Marksist.prototype = marks;
var kamenev = new Marksist("Каменев");
console.log(kamenev);   //  выведет объект с одним своим свойством и объектом marks в __proto__

Prototype имеет смысл только в конструкторах (пишутся в JS с большой буквы), он по сути выполняет лишь одно действие, а именно: указывает, куда ссылаться свойству __proto__ при инициализации функции-конструктора.

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

Marksist.prototype = {};
var chicherin = new Marksist("Чичерин");
console.log(chicherin.marxism);  //  undefined, в свойстве __proto__ будет стандартный прототип Object
console.log(kamenev.marxism);  //  по-прежнему true, в свойстве __proto__ будет объект marks

Видно, что у нового объекта с пустым прототипом нет унаследованных свойств, как у первого объекта. Но всё можно переиграть на лету:

Marksist.prototype = marks;
var zinovev = new Marksist("Зиновьев");		
console.log(zinovev.marksizm);   //   true
console.log(zinovev.deal());   //   future

Следует отметить, что изменение прототипов считается весьма затратной операцией, поэтому играть с прототипами “на лету” не рекомендуется!

В прототипе мы также можем задавать методы, которые будут использовать все потомки:

var marks = {
   marxism: true,
   engels: "friend",
   beard: 80,
   shout: function(){
      alert("Я есть товарищ " + this._name + "!")
   }
}
function Marksist(name) {
   this._name = name;			
}
Marksist.prototype = marks;
var dzerjinskiy = new Marksist("Дзержинский");
dzerjinskiy.shout();  //  Я есть товарищ Дзержинский!

Здесь this – это объект, у которого вызывается функция из прототипа, в данном случае Дзержинский.

Феликс Эдмундович Дзержинский
Правильно Феликс Эдмундович нас предупреждает: в JavaScript всегда надо быть бдительным насчёт того, куда в данный момент указывает ключевое слово this

Можно проверить, а является ли объект наследником конструктора, с помощью оператора instanceof:

var zemlyachka = function(tov) {
   var res = false;				
   if (tov instanceof Marksist) res = true;
   return res;
}
console.log(zemlyachka(zinovev));   //  true

Приводим оппортуниста, который будет иметь на практике все те же свойства и методы, что и обычный марксист:

var opportunist = {
   name: "Преображенский",
   marxism: true,
   engels: "friend",
   beard: 80,
   shout: function(){
      alert("Я есть товарищ " + this.name + "!")
   }
};		
opportunist.shout();

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

var plehanov = {
   marxism: true,
   engels: "friend",
   beard: 80,
   shout: function(){
      alert("Я есть товарищ " + this._name + "!")
   }
}
function Socialist (name){
   this._name = name;
}
Socialist.prototype = plehanov;
var opportunist = new Socialist("Попутчик");
console.log(opportunist);  
// структура объекта и прототипа идентичная таковой объекта var zinovev = new Marksist, имена свойств те же

Однако на проверке он завалится:

console.log(zemlyachka(opportunist));  //  false

Розалия Самойловна Землячка
Розалия Самойловна видит объект насквозь. Есть и другой подход к проверке объектов —
Duck Typing
Если это выглядит как утка, плавает как утка и крякает как утка, то это, возможно, и есть утка.
Если субъект объект ведёт себя так, как нам нужно, то мы считаем его коммунистом тем, кем нужно считать, несмотря на его происхождение


Однако и проверенные коммунисты иногда могут ошибаться. Оператор instanceof сравнивает только прототипы объекта и конструктора, поэтому возможны коллизии наподобие этой:

var troczkiy = new Marksist("Троцкий");
Marksist.prototype = {};
console.log(troczkiy instanceof Marksist);  //  опа, 1940-й год подкрался незаметно!

Ну и конечно помним, что всё в JS является объектом (а если точнее, в цепочке прототипов все объекты, кроме специальных пустых, приходят к Object.prototype), поэтому проверка оба раза выдаст true:

var john_reed = [10];
console.log(john_reed instanceof Array);  //  true
console.log(john_reed instanceof Object);  //  true

Свойство constructor

У функций-конструкторов (да и вообще всех функций) есть свойство prototype, в котором записан constructor: он возвращает ссылку на функцию, создавшую прототип экземпляра. Его можно легко потерять в дальнейших преобразованиях, т.к. JS не обязан сохранять эту ссылку. Допустим, мы решили всё-таки выяснить политические корни Льва Давыдовича:

var marks = {
   marxism: true,
   engels: "friend",
   beard: 80
} 
function Marksist(name) {
   this._name = name;
} 
Marksist.prototype = marks;
var troczkiy = new Marksist("Троцкий");
var Congress = troczkiy.constructor;
var retrospective = new Congress("My life");
console.log(retrospective);  //  чёрти что, конструктор делает явно не то, чего мы от него ожидаем!

Лев Давыдович Троцкий

Очевидно, мы не смогли вызвать ту же функцию-конструктор, которая создала бы нам новый объект, идентичный первому (хотя constructor, по идее, должен был бы указывать на неё!). Чтобы получить нужный результат, просто сохраним свойство constructor в прототипе Marksist:

var marks = {
   marxism: true,
   engels: "friend",
   beard: 80
} 
function Marksist(name) {
   this._name = name; 
} 
Marksist.prototype = marks;
Marksist.prototype.constructor = Marksist;
var troczkiy = new Marksist("Троцкий");
var Congress = troczkiy.constructor;
var retrospective = new Congress("My life");
console.log(retrospective);  //  вот теперь всё как нужно!

Таким образом, нам не обязательно знать, каким конструктором создавался экземпляр, от которого теперь мы создаём новый экземпляр. Эта информация записана в самом экземпляре.

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

Первый герб СССР 1924 год

Нас ничто не остановит от того, чтобы, например, поменять метод Object.prototype:

Object.prototype.toString = function(){
   alert("К стене!")
}

Или даже не такой экстремистский пример:

Array.prototype.manifest= function(){
   return "Призрак бродит по Европе - призрак коммунизма";
}

Такой стиль изменения классов (здесь точнее было бы сказать прототипов) называется monkey patching.

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

И во-вторых, переопределение в процессе выполнения программы может приводить к неочевидным результатам:

var pss = {
   name: "Полное собрание сочинений",
   author: "Ленин",
   length: 20
}

var Books = function(){};
Books.prototype = pss;
var firstEdition = new Books;
console.log(firstEdition.length);  //  20
var extendLenin = function(year){
   if (!year) var year = new Date().getFullYear();
   if (year > 1925 && year < 1932) pss.length = 30;
   if (year > 1941 && year < 1966) pss.length = 35;
   if (year > 1966) pss.length = 55;
}
extendLenin();
var fourthEdition = new Books;
console.log(fourthEdition.length);  //  ??

Поведение свойств объектов, наследующих от прототипа (т.н. «экземпляров класса») может быть не таким, как мы предполагаем.

Функциональное наследование

В JavaScript, кроме прототипной парадигмы наследования, используется также функциональная. На примере одного из героев Революции посмотрим, как она реализуется.

Создадим конструктор, который:

а) Принимает параметры
б) Обладает публичными методами, которые предполагаются для использования извне конструктора и его производных
в) Обладает приватными методами, которые, как предполагаются, будут использоваться только внутри конструктора и его производных

Перед нами типичный коммунист:

var Kommunist = function(principles) {
   if (!principles) principles = {};
   this._start = 1902;
   if (principles && principles.start) this._start = principles.start;
   // публичный метод, предполагается к использованию извне
   this.response = function() {
      alert("Наше дело правое!")
   }    
   // приватный метод, по соглашению доступен внутри конструктора и его потомков
   this._experience = function() {
      return (this._start);
   }     
   this.principles = (Object.keys(principles).length > 0 ? principles : {fidelity: 100});  
   this._getPrinciples = function() {
      return this.principles
   }
}

Приватные методы принято писать, начиная с нижнего подчёркивания.

Итак, у нас есть конструктор с набором методов, готовый принимать и обрабатывать аргументы. Создаём экземпляр класса:

Климент Ефремович Ворошилов

function Voroshilov(principles) {
 
   Kommunist.apply(this, arguments);   
   
   //  расширяем метод конструктора
   var parentExperience = this._experience;
   this._experience = function() {
      return ("Стаж в ВКП(б) с " + parentExperience.apply(this, arguments));      
   }
   
   // публичные методы, обращаемся к ним извне
   // геттеры
   this.getPrinciples = function() {
      var p = this._getPrinciples();   
      var char = {
         fidelity: p.fidelity,
         persistence: p.persistence || "достаточная!"
      }
      console.log("Верность: " + char.fidelity + ", стойкость: " + char.persistence)
   }
   
   this.getExperience = function() {   	
      console.log(this._experience());
      alert("Опыт ого-го!");
   }
   // сеттер
   this.setExperience = function() {
      this._experience = function() {
         return ("Стаж в ВКП(б) со Второго съезда");  
      }
   }
}
var ke = {fidelity: 101, persistence: 100, start: 1903}
var voroshilov = new Voroshilov(ke);

Обратите внимание: конструктор вызывается относительно this, чтобы записать в него все свои методы, и с массивом arguments, в котором содержатся все аргументы, заданные при вызове (объект ke).

Дальше мы можем наблюдать, как работают геттер и сеттер, а также другие публичные методы:

voroshilov.getExperience();  //  получили значение
voroshilov.setExperience();  //  заменили метод предустановленным в экземпляре класса
voroshilov.getExperience();  //  получили новое значение
voroshilov.getPrinciples();  //  получили результат выполнения публичного метода с заданными параметрами

Для разнообразия можно вызвать конструктор без параметров.

Классовая сущность

Наконец, с выходом ES6 (ES2015) у нас появилась возможность использовать инструкцию class непосредственно в JavaScript. По сути, в устройстве прототипного наследования ничего не изменилось, однако теперь JS поддерживает синтаксический сахар, который будет более привычен многим программистам, пришедшим из других языков.

class Marksist {
   constructor(name) {
      this._name = name
   }
   enemy() {
      return "capitalism"
   }				
   static revolution(){
      return "Революция нужна"
   }
}

У классов JS есть три вида методов:

— constructor (выполняется при инициализации экземпляра класса);
— static (статичные методы, доступные при вызове класса, но недоступные в экземплярах);
— обычные методы.

Теперь запомним в константе (ES6 допускает и такой тип переменных) очень важную дату, а далее определим меньшевика, который является наследником марксиста:

const cleavage = 1903;

class Menshevik extends Marksist {
   constructor(name) {
      super();
      this._name = name;					
   }
   static revolution() {
      var r = super.revolution();
      return r + ", но потом";
   }
   ["che" + cleavage]() {
      alert("Пароль верный!")
   }
   hurt() {
      alert("Ленин был прав")
   }
}

let chkheidze = new Menshevik("Чхеидзе");

image

Здесь есть два новшества:

super() в первом случае инициализирует конструктор базового класса, во втором вызывает метод, к которому мы в потомке добавляем новое поведение;
— вычисляемые имена методов ([«che» + cleavage]), теперь нам не обязательно сразу знать имя метода.

Статический метод доступен при вызове класса, но не при вызове экземпляра:

console.log(Menshevik.revolution());   //   работает
console.log(chkheidze.revolution());   //   is not a function, в экземпляре её нет

Результат выполнения следующего кода уже понятен:

chkheidze.hurt();   //   вызов метода класса
console.log(chkheidze.enemy());   //   вызов метода базового класса			
chkheidze.che1903();   //   вызов метода с вычисляемым именем

Выше были показаны самые основные особенности наследования через классы (class) в JavaScript. Сознательный пролетарий при должной революционной настойчивости найдёт в сети немало статей, более полно освещающий вопрос нововведений в ES6.