— Прототипное наследование — это прекрасно
JavaScript — это объектно-ориентированный (ОО) язык, уходящий корнями в язык Self, несмотря на то, что внешне он выглядит как Java. Это обстоятельство делает язык действительно мощным благодаря некоторым приятным особенностям.
Одна из таких особенностей — это реализация прототипного наследования. Этот простой концепт является гибким и мощным. Он позволяет сделать наследование и поведение сущностями первого класса, также как и функции являются объектами первого класса в функциональных языках (включая JavaScript).
К счастью, в ECMAScript 5 появилось множество вещей, которые позволили поставить язык на правильный путь (некоторые из них раскрыты в этой статье). Также будет рассказано о недостатках дизайна JavaScript и будет произведено небольшое сравнение с классической моделью прототипного ОО (включая его достоинства и недостатки).
Статья предполагает, что вы уже знакомы с основами JavaScript, имеете представление о функциях (включая концепты замыкания и функций первого класса), примитивных значениях, операторах и т.д.
1. Объекты
Всё, с чем вы работаете в JavaScript является объектами. Всё, включая Strings, Arrays, Numbers, Functions, и, очевидно, т.н. Object — это примитивы, но они конвертируются в объекты, когда вы пытаетесь оперировать ими. Объект в JavaScript — это просто коллекция пар ключ-значение (и иногда немного внутренней магии).
Однако, в JavaScript нет концепции класса. К примеру, объект с свойствами {name: Linda, age: 21} не является экземпляром какого-либо класса или класса Object. И Object, и Linda являются экземплярами самих себя. Они определяются непосредственно собственным поведением. Тут нет слоя мета-данных (т.е. классов), которые говорили бы этим объектам как нужно себя вести.
Вы можете спросить: «Да как так?», особенно если вы пришли из мира классических объектно-ориентированных языков (таких как Java или C#). «Но если каждый объект обладает собственным поведением (вместо того чтобы наследовать его от общего класса), то если у меня 100 объектов, то им соответствует 100 разных методов? Разве это не опасно? А как мне узнать, что, например, объект действительно является Array-ем?»
Чтобы ответить на все эти вопросы необходимо забыть о классическом ОО-подходе и начать всё с нуля. Поверьте, оно того стоит.
Модель прототипного ОО приносит несколько новых динамичных и экспрессивынх путей решения старых проблем. В ней также представлены мощные модели расширения и повторного исследования кода (а это и интересует людей, которые говорят об объектно-ориентированном программировании). Однако, эта модель даёт меньше гарантий. Например, нельзя полагаться, что объект x всегда будет иметь один и тот же набор свойств.
1.1. А что такое объекты?
Ранее упоминалось, что объекты — это просто пары уникальных ключей с соответствующими значениями — такие пары называются свойства. К примеру, вы хотите описать несколько аспектов своего старого друга (назовём его Мишей, он же Mikhail), таких как возраст, имя и пол:
Объект в JavaScript создаётся с помощью функции Object.create. Эта функция из родителя и опционального набора свойств создаёт новую сущность. Пока что мы не будем беспокоиться о параметрах.
Пустой объект — это объект без родителя, без свойств. Посмотрим на синтакс создания такого объекта в JavaScript:
var mikhail = Object.create(null)
1.2. Создание свойств
Так, значит у нас уже есть объект, но у него пока нет свойств — мы должны исправить эту ситуацию для описания нашего объекта Mikhail.
Свойства в JavaScript являются динамическими. Это означает, что мы их можем создавать или удалять в любое время. Свойства уникальны в том смысле, что ключ свойства внутри объекта соответствует ровно одному значению.
Создадим новые свойства через функцию Object.defineProperty, которая в качестве аргументов использует объект, имя свойства для создания и дескриптор, описывающий семантику свойства.
Object.defineProperty(mikhail, 'name', { value: 'Mikhail'
, writable: true
, configurable: true
, enumerable: true })
Object.defineProperty(mikhail, 'age', { value: 19
, writable: true
, configurable: true
, enumerable: true })
Object.defineProperty(mikhail, 'gender', { value: 'Male'
, writable: true
, configurable: true
, enumerable: true })
Функция Object.defineProperty создаёт новое свойство, если свойство с данным ключём ранее не существовало (в противном случае произойдёт обновление семантики и значения существующего свойства).
Кстати, вы также можете использовать Object.defineProperties когда необходимо добавить больше одного свойства в объект:
Object.defineProperties(mikhail, { name: { value: 'Mikhail'
, writable: true
, configurable: true
, enumerable: true }
, age: { value: 19
, writable: true
, configurable: true
, enumerable: true }
, gender: { value: 'Male'
, writable: true
, configurable: true
, enumerable: true }})
Очевидно, что оба вызова аналогичны, они вполне конфигурируемы, но не предназначены для конечного пользователя кода. Лучше создать уровень абстракции над ними.
1.3. Дескрипторы
Маленькие объекты, которые содержат в себе семантику, называются дескрипторами (мы их использовали при вызове Object.defineProperty). Дескрипторы бывают одного из двух типов — дескрипторы данных и дескрипторы доступа.
Оба типа дескрипторов содержат флаги, которые определяют как свойство будет рассматриваться языком. Если флаг не установлен, то его значение по умолчанию -false (к сожалению это не всегда хорошее значение по умолчанию, что влечёт возрастание объёма описания дескрипторов).
Рассмотрим некоторые флаги:
-writable — значение свойства может быть изменено, используется только для дескрипторов данных.
-configurable — тип свойства может быть изменён или свойство может быть удалено.
-enumerable — свойство используется в общем перечислении.
Дескрипторы данных таковы, что определяют конкретное значение, которое соответствует дополнительному value-параметру, описывающему конкретные данные, привязанные к свойству:
-value — значение свойства
Дескрипторы доступа определяют доступ к конкретному значению через getter-ы и setter-ы функций. Если не установлены, то по умолчанию равны undefined.
-get() — функция вызывается без аргументов, когда происходит запрос к значению свойства.
-set(new_value) — функция вызывается с аргументом — новым значением для свойства, когда пользователь пытается модифицировать значение свойства.
1.4. Стремимся к лаконичности
К счастью, дескрипторы свойств — это не единственный путь работать со свойствами в JavaScript — их можно создавать более лаконично.
JavaScript также понимает ссылки на свойства, используя так называемую скобочную запись. Основное правило записывается следующим образом:
<bracket-access> ::= <identifier> "[" <expression> "]"
Тут identifier — это переменная, которая хранит объект, содержащий свойство, значение которого мы хотим установить, а expression — любое валидное JavaScript-выражение, определяющее имя свойства. Нет ограничений на то, какое имя может иметь свойств, всё позволяется.
Таким образом, мы можем переписать предыдущий пример:
mikhail['name'] = 'Mikhail'
mikhail['age'] = 19
mikhail['gender'] = 'Male'
На заметку: все имена свойств в конечном счёте конвертируются в строку, т.е. записи object[1], object[[1]], object['1'] и object[variable] (где значение variable равно 1) эквивалентны.
Существует другой способ обращения к свойству, который называется точечной записью. Он выглядит проще и лаконичнее, чем скобочная альтернатива. Однако, при этом способе имя свойство должно соответствовать правилам валидного JavaScript-идентификатора и не может быть представлено выражением (т.е. нельзя использовать переменные).
Общее правило для точечной записи:
<dot-access> ::= <identifier> "." <identifier-name>
Таким образом, предыдущий пример стал ещё более красивым:
mikhail.name = 'Mikhail'
mikhail.age = 19
mikhail.gender = 'Male'
Оба варианта синтаксиса выполняют эквивалентный процесс создания свойств, с выставлением семантических флагов в значение true.
1.5. Доступ к свойствам
Очень просто получить значение, хранящиеся в заданном свойстве — синтаксис очень похож на создание свойства с той лишь разницей, что в нём нет присваивания.
Например, если мы хотим узнать возраст Миши, то мы напишем:
mikhail['age']
// => 19
Но если мы попробуем получить значение свойства, которого не существует в нашем объекте, то мы получим undefined:
mikhail['address']
// => undefined
1.6. Удаление свойств
Для удаления свойства из объекта в JavaSCript предусмотрен оператор delete. К примеру, если вы хотите удалить свойство gender из нашего объекта mikhail:
delete mikhail['gender']
// => true
mikhail['gender']
// => undefined
Оператор delete вернёт true, если свойство удалено, и falseв противном случае. Не будем углубляться в то, как работает этот оператор. Но если вам всё-таки интересно, то вы можете почитать самую прекрасную статью о том как работает delete.
1.6. Getter-ы и setter-ы
Getter-ы и setter-ы обычно используются в классических объектно-ориентированных языках для обеспечения инкапсуляции. Они не особо нужны в JavaScript, но, у нас динамический язык, и я против этой функциональности.
Но, с любой точки зрения, они позволяет обеспечить proxy для запросов на чтение и запись свойств. Например, у нас были отдельные слоты для имени и фамилии, но мы хотим иметь удобный способ читать и устанавливать их.
Для начала, создадим имя и фамилию нашего друга, описав соответствующие свойства:
Object.defineProperty(mikhail, 'first_name', { value: 'Mikhail'
, writable: true })
Object.defineProperty(mikhail, 'last_name', { value: 'Weiß'
, writable: true })
Затем мы опишем общий способ получения и установки сразу двух свойств за один раз — назовём их объединение name:
// () → String
// Returns the full name of object.
function get_full_name() {
return this.first_name + ' ' + this.last_name
}
// (new_name:String) → undefined
// Sets the name components of the object, from a full name.
function set_full_name(new_name) { var names
names = new_name.trim().split(/s+/)
this.first_name = names['0'] || ''
this.last_name = names['1'] || ''
}
Object.defineProperty(mikhail, 'name', { get: get_full_name
, set: set_full_name
, configurable: true
, enumerable: true })
Теперь, каждый раз когда мы попытаемся узнать значение свойства name нашего друга на самом деле вызовется функция get_full_name:
mikhail.name
// => 'Mikhail Weiß'
mikhail.first_name
// => 'Mikhail'
mikhail.last_name
// => 'Weiß'
mikhail.last_name = 'White'
mikhail.name
// => 'Mikhail White'
Мы также можем установить name объекта, обратившись к соответствующему свойству, но на самом деле вызов set_full_name выполнит всю грязную работу:
mikhail.name = 'Michael White'
mikhail.name
// => 'Michael White'
mikhail.first_name
// => 'Michael'
mikhail.last_name
// => 'White'
Есть сценарии, в которых действительно удобно так делать, но стоит помнить, что такой механизм работает очень медленно.
Кроме того, следует учитывать что getter-ы и setter-ы обычно используются в других языках для инкапсуляции, а в ECMAScript 5 вы всё ещё не можете так делать — все свойства объекта являются публичными.
1.8. Перечисление свойств
Ввиду того, что свойства являются динамическими, JavaScript обеспечивает функционал по проверке набора свойств объекта. Существует два способа перечисления всех свойств объекта, зависящих от того, какой вид свойств вас интересует.
Первый способ заключается в вызове функции Object.getOwnPropertyNames, которая вернёт вам Array, содержащий имена всех свойств, установленных для данного объекта — мы будет называть эти свойства собственными. Например, посмотрим, что мы знаем о Мише:
Object.getOwnPropertyNames(mikhail)
// => [ 'name', 'age', 'gender', 'first_name', 'last_name' ]
Второй способ заключается в использовании Object.keys, который вернёт список собственных свойств, которые помечены флагом enumerable :
Object.keys(mikhail)
// => [ 'name', 'age', 'gender' ]
1.9. Литералы
Простой способ создать объект заключается в использовании литерального синтаксиса JavaScript. Литеральный объект определяет новый объект, родитель которого Object.prototype (о родителях поговорим немного позже).
В любом случае, синтаксис литеральных объектов позволяет определять простые объекты и инициализировать их свойства. Перепишем пример создания объекта Mikhail:
var mikhail = { first_name: 'Mikhail'
, last_name: 'Weiß'
, age: 19
, gender: 'Male'
// () → String
// Returns the full name of object.
, get name() {
return this.first_name + ' ' + this.last_name }
// (new_name:String) → undefined
// Sets the name components of the object,
// from a full name.
, set name(new_name) { var names
names = new_name.trim().split(/s+/)
this.first_name = names['0'] || ''
this.last_name = names['1'] || '' }
}
Невалидные имена свойств могут быть заключены в кавычки. Учитывайте, что запись для getter/setter в литеральном виде определяется анонимными функциями. Если вы хотите связать ранее объявленную функцию с getter/setter, то вы должны использовать метод Object.defineProperty.
Посмотрим на общее правила литерального синтаксиса:
<object-literal> ::= "{" <property-list> "}"
;
<property-list> ::= <property> ["," <property>]*
;
<property> ::= <data-property>
| <getter-property>
| <setter-property>
;
<data-property> ::= <property-name> ":" <expression>
;
<getter-property> ::= "get" <identifier>
: <function-parameters>
: <function-block>
;
<setter-property> ::= "set" <identifier>
: <function-parameters>
: <function-block>
;
<property-name> ::= <identifier>
| <quoted-identifier>
;
Литеральные объекты могут появляться внутри выражений в JavaScript. Из-за некоторой неоднозначности новички иногда путаются:
// This is a block statement, with a label:
{ foo: 'bar' }
// => 'bar'
// This is a syntax error (labels can't be quoted):
{ "foo": 'bar' }
// => SyntaxError: Invalid label
// This is an object literal (note the parenthesis to force
// parsing the contents as an expression):
({ "foo": 'bar' })
// => { foo: 'bar' }
// Where the parser is already expecting expressions,
// object literals don't need to be forced. E.g.:
var x = { foo: 'bar' }
fn({foo: 'bar'})
return { foo: 'bar' }
1, { foo:
2. Методы
До сих пор объект Mikhail имел только слоты для хранения данных (ну, за исключением getter/setter для свойства name). Описание действий, которые можно делать с объектом делается в JavaScript очень просто. Просто — потому что в JavaScript нет разницы между манипулированием такими вещами, как Function, Number, Object. Всё делается одинаково (не забываем, что функции в JavaScript являются сущностями первого класса).
Опишем действие над данным объектом, просто установив функцию, как значение нашего свойство. К примеру, мы хотим, чтобы Миша мог приветствовать других людей:
// (person:String) → String
// Greets a random person
mikhail.greet = function(person) {
return this.name + ': Why, hello there, ' + person + '.'
}
После выставления значения свойства, мы можем использовать аналогичный способ для выставления конкретных данных, связанных с объектом. Таким образом, доступ к свойствам будет возвращать ссылку на функцию, хранящуюся в нём, которую мы можем вызвать:
mikhail.greet('you')
// => 'Michael White: Why, hello there, you.'
mikhail.greet('Kristin')
// => 'Michael White: Why, hello there, Kristin.'
2.1. Динамический this
Следует учитывать одну вещь при описании функции greet — эта функция должна обращаться к getter/setter свойства name, а для этого она использует магическую переменную this.
Она хранит в себе ссылку на объект, которому принадлежит исполняющаяся функция. Это не обязательно означает, что this всегда равно объекту, в котором функция хранится. Нет, JavaScript не настолько эгоистичен.
Функции являются generic-ами. Т.е. в JavaScript переменная this определяет динамическую ссылку, которая разрешается в момент исполнения функции.
Процесс динамического разрешения this обеспечивает невероятно мощный механизм для динамизации объектной ориентированности JavaScript и компенсирует отсутствие строгого соответствия заданным структурам (т.е. классам). Это означает, что можно применить функцию к любому объекту, который отвечает требованиям запуска, независимо от того, как устроен объект (как и в CLOS).
2.2. Разрешение this
Существует четыре различных способа разрешения this в функции, зависящие от того, как функция вызывается: непосредственно, как метод, явно применяется, как конструктор. Мы посмотрим первые три, а к конструкторам вернёмся позже.
Для следующих примеров вы примем:
// Returns the sum of the object's value with the given Number
function add(other, yet_another) {
return this.value + other + (yet_another || 0)
}
var one = { value: 1, add: add }
var two = { value: 2, add: add }
2.2.1 Вызов как метод
Если функция вызывается, как метод объекта, то this внутри функции ссылается на сам объект. Т.е. когда мы явно указываем какой объект выполняет действие, то объект и будет значением this в нашей функции.
Это произойдёт, когда мы вызовем mikhail.greet(). Эта запись говорит JavaScript-у, что мы хотим применить действие greet к объекту mikhail.
one.add(two.value) // this === one
// => 3
two.add(3) // this === two
// => 5
one['add'](two.value) // brackets are cool too
// => 3
2.2.2 Непосредственный вызов
Когда функция вызывается непосредственно, то this разрешается в глобальный объект движка (window в браузере, global в Node.js)
add(two.value) // this === global
// => NaN
// The global object still has no `value' property, let's fix that.
value = 2
add(two.value) // this === global
// => 4
2.2.3. Явное применение
В заключении, функция может быть явно применена к любому объекту, несмотря на то, есть ли в объекте соответствующее свойство или нет. Эта функциональность достигается с помощью методов call или apply.
Различие между двумя методами заключается в параметрах передаваемых в функцию и времени исполнения — apply работает примерно в 55 раз медленнее, чем непосредственный вызов, а вот call обычно не особо хуже. Всё очень зависит от текущего движка, так что используйте Perf test, чтобы быть уверенными — не оптимизируйте код раньше времени.
В любом случае, call ожидает объект, как первый параметр функции, за котором следуют обычные аргументы исходной функции:
add.call(two, 2, 2) // this === two
// => 6
add.call(window, 4) // this === global
// => 6
add.call(one, one.value) // this === one
// => 2
С другой стороны, apply позволяет описывать вторым параметром массив параметров исходной функции:
add.apply(two, [2, 2]) // equivalent to two.add(2, 2)
// => 6
add.apply(window, [ 4 ]) // equivalent to add(4)
// => 6
add.apply(one, [one.value]) // equivalent to one.add(one.value)
// => 2
На заметку. Учтите, что разрешение this в null или undefined зависит от семантики используемого движка. Обычно результат бывает таким же, как и применение функции к глобальному объекту. Но если движок работает в strict mode, то this будет разрешено как и ожидается — ровно в ту вещь, к которой применяется:
window.value = 2
add.call(undefined, 1) // this === window
// => 3
void function() {
"use strict"
add.call(undefined, 1) // this === undefined
// => NaN
// Since primitives can't hold properties.
}()
2.3. Связывание методов
Отвлечёмся от динамической сущности функций в JavaScript, пойдём по пути создания функций, связывая их с определёнными объектами, так чтобы this внутри функции всегда указывал на данный объект, несмотря на то, как он вызывается — как метод объекта или непосредственно.
Функция обеспечивает функциональность, называемую bind: берётся объект и дополнительный параметр (очень похоже на вызов call) и возвращается новая функция, которая будет применять параметры к исходной функции при вызове:
var one_add = add.bind(one)
one_add(2) // this === one
// => 3
two.one_adder = one_add
two.one_adder(2) // this === one
// => 3
one_add.call(two) // this === one
// => 3
3. Наследование
До сих пор мы видели, как объекты могут определять своё поведение и как мы можем использовать их действия на других объектах, но, мы до сих пор не увидели нормального пути для повторного использования кода и его расширяемости.
Вот тут-то нам и пригодится наследование. Оно позволит разделить задачи, в которых объекты определяют специализированное поведение от создания общего поведения для других объектов.
Модель прототипирования идёт дальше. Хоть она и поддерживает такие технологии, как «selective extensibility» и «behaviour sharing», но мы их не будем особо изучать. Печальная вещь: конкретные модели прототипного ОО, реализованные в JavaScript несколько ограниченны. Мы можем обойти эти ограничения, но накладные расходы будут велики.
3.1. Прототипы
Наследование в JavaScript осуществляется через клонирование поведения объекта и расширение его специализированным поведением. Объект, поведение которого клонируют, называется прототипом.
Прототип — это обычный объект, который делится своим поведением с другими объектами — в этом случае он выступает в качестве родителя.
Концепт клонирования поведения не означает, что вы будете иметь две различные копии одной и той же функции или данных. На самом деле JavaScript реализует наследование через делегирование, т.е. все свойства хранятся в родителе, а доступ к ним расширен через ребёнка.
Как упоминалось ранее, родитель (или [[Prototype]]) объекта определяется вызовом Object.create с первым аргументом, ссылающимся на объект-родитель.
Вернёмся к примеру с Мишей. Выделим его имя и способность приветствовать людей в отдельный объект, который поделится с Мишей своим поведением. Вот как будет выглядеть наша модель:
Реализуем её на JavaScript:
var person = Object.create(null)
// Here we are reusing the previous getter/setter functions
Object.defineProperty(person, 'name', { get: get_full_name
, set: set_full_name
, configurable: true
, enumerable: true })
// And adding the `greet' function
person.greet = function (person) {
return this.name + ': Why, hello there, ' + person + '.'
}
// Then we can share those behaviours with Mikhail
// By creating a new object that has it's [[Prototype]] property
// pointing to `person'.
var mikhail = Object.create(person)
mikhail.first_name = 'Mikhail'
mikhail.last_name = 'Weiß'
mikhail.age = 19
mikhail.gender = 'Male'
// And we can test whether things are actually working.
// First, `name' should be looked on `person'
mikhail.name
// => 'Mikhail Weiß'
// Setting `name' should trigger the setter
mikhail.name = 'Michael White'
// Such that `first_name' and `last_name' now reflect the
// previously name setting.
mikhail.first_name
// => 'Michael'
mikhail.last_name
// => 'White'
// `greet' is also inherited from `person'.
mikhail.greet('you')
// => 'Michael White: Why, hello there, you.'
// And just to be sure, we can check which properties actually
// belong to `mikhail'
Object.keys(mikhail)
// => [ 'first_name', 'last_name', 'age', 'gender' ]
3.2 Но как же [[Prototype]] работает?
Как вы видели в прошлом примере, ни одно из свойств, определённых в Person мы не определяли явно в Mikhail, но всё же смогли получить к ним доступ. Это произошло благодаря тому, что JavaScript реализует делегирование доступа к свойствам, т.е. свойство ищется через всех родителей объекта.
Эта цепь родителей определяется скрытым слотом в каждом объекте, который называется [[Prototype]]. Вы не можете изменить его непосредственно, существует только один способ задать ему значение — при создании нового объекта.
Когда свойство запрашивается из объекта, движок сначала пытается получить свойство из целевого объекта. Если свойство не найдено, то рассматривается непосредственный родитель объекта, затем родитель родителя и т.д.
Это означает, что мы можем изменить поведение прототипа в середине программы, то автоматически изменится поведение всех объектов, которые были от него унаследованы. Например, пусть мы хотим изменить приветствие по умолчанию:
// (person:String) → String
// Greets the given person
person.greet = function(person) {
return this.name + ': Harro, ' + person + '.'
}
mikhail.greet('you')
// => 'Michael White: Harro, you.'
3.3. Перегрузка свойств
Так, прототипирование (т.е. наследование) используется для того, чтобы можно было поделиться данными с другими объектами. Причём этот способ работает быстро и экономичен по отношению к памяти, т.к. мы всегда имеем только один экземпляр используемых данных.
Но что если мы хотим добавить специализированное поведение, которым можно было бы делиться с другим объектами? Мы уже видели как объекты определяют своё поведение с помощью свойств, специализированное поведение определяется аналогичным образом — вы просто устанавливаете значение нужному свойству.
Для лучшей демонстрации положим, что Person реализует только обобщённое приветствие, а каждый наследник Person будет реализовывать своё уникальное приветствие. Также добавим новую персону в наш сценарий, чтобы лучше показать как расширяется объект:
Учтите, что и mikhail, и kristin определяют собственную версию метода greet. В этом случае мы вызовем метод greet из собственной версии поведения объекта, а не обобдённый метод greet, унаследованный от Person:
// Here we set up the greeting for a generic person
// (person:String) → String
// Greets the given person, formally
person.greet = function(person) {
return this.name + ': Hello, ' + (person || 'you')
}
// And a greeting for our protagonist, Mikhail
// (person:String) → String
// Greets the given person, like a bro
mikhail.greet = function(person) {
return this.name + ': 'sup, ' + (person || 'dude')
}
// And define our new protagonist, Kristin
var kristin = Object.create(person)
kristin.first_name = 'Kristin'
kristin.last_name = 'Weiß'
kristin.age = 19
kristin.gender = 'Female'
// Alongside with her specific greeting manners
// (person:String) → String
// Greets the given person, sweetly
kristin.greet = function(person) {
return this.name + ': 'ello, ' + (person || 'sweetie')
}
// Finally, we test if everything works according to the expected
mikhail.greet(kristin.first_name)
// => 'Michael White: 'sup, Kristin'
mikhail.greet()
// => 'Michael White: 'sup, dude'
kristin.greet(mikhail.first_name)
// => 'Kristin Weiß: 'ello, Michael'
// And just so we check how cool this [[Prototype]] thing is,
// let's get Kristin back to the generic behaviour
delete kristin.greet
// => true
kristin.greet(mikhail.first_name)
// => 'Kristin Weiß: Hello, Michael'
Продолжение следует...
Автор: DreamWalker