В ECMAScript пока отсутствует понятие «класс», в классическом понимании этого термина, однако, в CoffeeScript такое понятие есть, поэтому сегодня мы рассмотрим этот вопрос.
Содержание:
1. Основные понятия
2. Члены класса
2.1. Метод constructor
2.2. Открытые члены класса
2.3. Закрытые члены класса
2.4. Защищенные члены класса
2.5. Статические члены класса
3. Наследование
4. Дополнительная литература
Основные понятия
Класс — специальная синтаксическая конструкция представленная множеством обобщённых логически связанных сущностей.
Объект — неупорядоченное множество свойств, имеющее объект-прототип, значением которого может быть объект либо null.
Конструктор — объект типа Function, который создаёт и инициализирует объекты.
Прототип — используется для реализации наследования структуры, состояния и поведения.
Cвойство prototype автоматически создается для каждой функции, чтобы обеспечить возможность использования функции в качестве конструктора.
Инстанцирование — создание экземпляра класса.
Общая теория
CoffeeScript это динамический язык с прототипно-классовой парадигмой, в котором работа с классами реализована на уровне делегирующего прототипирования.
Если давать более точно определение, то класс в CoffeeScript это абстрактный тип данных, который определяет альтернативный способ работы с объектами.
Для того чтобы определить класс нужно использовать спецификатор class:
class
Результат трансляции:
(function() {
function Class() {};
return Class;
})();
Как видите, класс это это всего лишь обёртка (синтаксический сахар) представленная функцией-конструктором:
Object::toString.call class # [object Function]
Создание экземпляра класса (инстанцирование) осуществляется с помощью оператора new
class A
instance = new A
После применения оператора new к функции-конструктору, активируется внутренний метод [[Construct]], который отвечает за создание объекта:
class A
Object::toString.call new A # [object Object]
Иными словами, определение класса — не есть создание объекта, до тех пор, пока не произойдет инстанцирование:
class A
constructor:
(@variable) ->
A 1
variable # 1
Обратите внимание на то, что переменная variable стала доступна в глобальном пространстве имен.
Без инстанцирования, определение класса аналогично следующему коду:
A = (variable) ->
@variable = variable
A 1
variable # 1
После того как мы инициализируем создание объекта, this внутри конструктора будет указывать на создаваемый объект:
class A
constructor:
(@property) ->
object = new A 1
object.property # 1
property # undefined
Как видите, this уже не указывает на глобальный объект, и переменная property не определена. Тоже самое относится и к коду без спецификатора class:
A = (@property) ->
object = new A 1
object.property # 1
property # undefined
На самом деле, разницы между этими вариантами нет.
Однако стоит заметить, что синтаксис со спецификатором class более предпочтителен для создания таких объектов и сейчас я расскажу почему…
Если вы помните, то я уже упоминал о неявном добавлении инструкции return в функциях. Такое поведение может сыграть с нами очень злую шутку:
A = (variable) ->
method = -> variable
Вроде бы, вполне безобидный код?
Что же, давайте попробуем создать объект:
A = (param) ->
method = -> param
object = new A 1
object.method() # TypeError: has no method 'method'
Что за дела?
Чтобы понять почему так происходит достаточно посмотреть результат трансляции:
var A = function(param) {
return this.method = function() {
return param;
};
};
К сожалению нормального способа избежать подобных сюрпризов нет (я сейчас имею ввиду неявное добавление инструкции return).
Во-первых, мы можем предварить параметр символом @ и определить функцию в качестве инициализирующего параметра:
A = (@method, @param) ->
object = new A (-> @param), 1
do object.method # 1
Такое решение возможно благодаря тому что this определяется на момент создания объекта.
Давайте рассмотрим результат трансляции:
var A, object;
A = function(method, param) {
this.method = method;
this.param = param;
};
object = new A((function() {
return this.param;
}), 1);
object.method(); //1
Примечание: функции, принимающие в качестве параметров другие функции или возвращающие другие функции в качестве результата называются функции высшего порядка (first-class functions). В этом случае, параметр функции называется функциональным параметром или фунаргом.
Следующий способ, который позволяет нам определить члены объекта заключается в использовании объектов-прототипов:
A = (@param) ->
A::method = -> @param
object = new A 1
object.method() # 1
Стоит заметить, что определение свойств объекта напрямую, и через объект-прототип это не одно и тоже:
A = (@param) ->
A::param = 0
object = new A 1
object.param # 1
У собственных свойств приоритет выше, поэтому сперва анализируются они, а потом уже поиск производится в цепочке прототипов.
Напомню, что сейчас мы рассматриваем вопрос относительно неявного добавления инструкции return в функциях-конструкторах и способы решения этой проблемы.
A = (@param) ->
@method = ->
@param
@
object = new A 1
object.method() # 1
this будет указывать на вновь созданный объект.
При этом стоит учесть, что если возвращаемым значением будет объект, то именно он и будет являться результатом выражения new:
A = (@param) ->
@method = ->
@param
[]
object = new A 1
object # []
Object::toString.call(object) # [object Array]
object.method() # TypeError: Object has no method 'method'
Соответственно чтобы иметь доступ к свойству method нужно явно вернуть объект которому принадлежит это свойство:
A = (param) ->
object = {}
object.method = ->
param
object
object = new A 1
object.method() #1
Разумеется не важно как будет возвращаться ссылка на объект:
A = (param) ->
method: -> param
object = new A 1
object.method() #1
В данном случае, в функции-конструкторе A возвращается ссылка на анонимный объект.
Все тоже самое относится и к возвращению функций:
A = (one) ->
(two) -> one + two
object = new A 1
object 2 # 3
Как вы понимаете, использование оператора new в этом случае опционально. А this внутри вложенной функции будет указывать на глобальный объект:
A = ->
-> @
new A()() # global
Cтоит заметить, что при использовании строгого режима (use strict), this будет указывать на undefined:
A = -> 'use strict' -> @ new A()() # undefined
Чтобы this указывал на вновь созданный объект, нужно явно добавить оператор new:
A = ->
new -> @
new A() # object
Тогда у вас должен должен возникнуть встречный вопрос, как передать параметры вложенной функции:
A = (one) ->
new (two) -> one + two
object = new A 1
object.constructor 2 # 3
Прошу не путать внутренний CoffeeScript метод constructor() и ссылку на функцию constructor, через которую можно получить ссылку на прототип объекта:
A = ->
new ->
object = new A
object.constructor:: # object
object.constructor::constructor 2 # 3
Такое поведение осуществляется за счет диспетчеризации вызовов, когда объект следуя по цепочке делегирующих указателей не может найти соответствующее свойство он обращается к своему прототипу. Такая цепь обращений от объекта к прототипу называется цепью прототипов (prototype chain).
Подобное поведение хорошо знакомо программистам пишущих на языках Ruby, Python, SmallTalk и Self.
Примечание: к сожалению, для constructor нет псевдонима как для ptototype, но возможно в ближайшем будущем это будет учтено, т.к. во некоторых диалектах CoffeeScript это уже реализовано. Например в coco слово constructor можно заменить двоеточием (..):
@..:: # this.constructor.prototype
Члены класса
Метод constructor()
Конструктор класса — специальный метод constructor() определенный в теле класса и предназначенный для инициализации членов объекта.
class A
A::property = 1
object = new A
object.property # 1
В этом примере, мы инициализировали создание класса с именем A.
Единственный член класса — свойство property, которое формально находится в прототипе объекта A.
Т.к. this всегда будет указывать на A (и транслировано тоже), есть смысл переписать определение класса:
class A
@::property = 1
Несмотря на компактность записи, подобное определение членов класса не принято в CoffeScript. К тому же, есть более изящное решение:
class A
property: 1
Для того чтобы определить члены класса за его пределами, следует использовать первую форму записи:
class A
A::property = 1
Как уже отмечалось ранее, каждый экземпляр класса имеет ссылку на собственный конструктор, которому доступно свойство prototype.
Получив таким образом ссылку на первоначальный прототип объекта, можно определить новые члены класса:
class A
object1 = new A
object1.constructor::property = 1
object2 = new A
object2.property # 1
object2.constructor is A # true
Если нужно добавить несколько свойств в прототип, то есть смысл сделать это «скопом»:
class A
A:: =
property: 1
method: -> @property
object2 = new A
object2.method() # 1
Однако, в этом случае, свойство constructor будет указывать на другой объект:
class A
A:: = {}
object = new A
object.constructor is A # false
Несмотря на то, что ссылка на оригинальный прототип будет утеряна, мало кто это заметит:
class A
A:: = {}
object = new A
object.constructor::property = 1 # Опасно!
object.property # 1
Нужно быть особо внимательными в таких ситуациях, потому что добавив новое свойство через экземпляр класса мы добавим это свойство всем объектам!
Смотрим пример внимательно:
class A
A:: =
property: 1
method: -> @property
object = new A
object.constructor::property = 'Oh my god!'
object.method() # 1
object.property # 1
list = []
list.property # 'Oh my god!'
Такое поведение стало возможно в связи с тем, что свойство constructor теперь указывает на Object:
class A
A:: = {}
object = new A
object.constructor # [Function: Object]
object.constructor is Object # true
Иными словами, сами того не подозревая у нас получилось следующее:
Object::property = 'Oh my god!'
Разумеется мало кому понравится видеть сторонние методы в своих объектах.
Для того чтобы иметь правильную ссылку на исходный конструктор следует явно ее воссоздать:
class A
A:: = constructor: A
object = new A
object.constructor::property = 1
object.property # 1
object.constructor is A # true
Теперь ссылка на объект-прототип корректная.
Если вы помните, то мы начинали рассматривать метод класса constructor(). Так вот, единственное назначение этого метода — инициализация параметров:
class A
constructor: (param) ->
@param = param
object = new A 1
object.param # 1
Соответственно, если нет необходимости в передаче параметров конструктору класса — разумно будет опустить метод constructor().
Идеологически так сложилось, что конструктор не должен возвращать никаких значений и не может быть перегружен (в CoffeeScipt отсутствует перегрузка операторов и функций).
Так как передача параметров в функцию-конструктор довольно частая операция, в CoffeeScript предусмотрен специальный синтаксис:
class A
constructor: (@param) ->
object = new A 1
object.param # 1
Давайте посмотрим на результат трансляции:
var A, object;
A = (function() {
function A(param) {
this.param = param;
}
return A;
})();
object = new A(1);
object.param; //1
Как видите, param является прямым свойством свойством объекта A, т.е. в любой момент его можно модифицировать и даже удалить.
class A
constructor: (@param) ->
object = new A 1
object.param = 2
object.param # 2
В этом случае, мы переопределили значение свойства конкретного экземпляра класса, что никак не отразится на другие экземпляры.
Открытые члены класса (public)
Во многих объектно-ориентированных языках инкапсуляция определяется по средствам применения таких модификаторов как public, private, protected и в какой-то степени static. В СoffeeScript для этих целей предусмотрен несколько иной подход, предлагаю начать рассмотрение этого вопроса с открытых членов (public)
Все открытые члены класса записываются в ассоциативной нотации, без ведущего символа @ и/или this:
class A
constructor: (@param) ->
method: -> @param
object = new A 1
object.method() # 1
Результат трансляции:
var A, object;
A = (function() {
function A(param) {
this.param = param;
}
A.prototype.method = function() {
return this.param;
};
return A;
})();
object = new A(1);
object.method(); //1
Из результата трансляции видно, что открытые члены класса добавляются в прототип объекта A.
Следовательно, обращение членам класса, технически осуществляется как в любом другом объекте:
class A
property: 1
method: ->
@property
object = new A
object.method() # 1
Закрытые члены класса (private)
В закрытых членах класса обращения к члену допускаются только из методов того класса, в котором этот член определён. Наследники класса не имеют доступа к закрытым членам.
Закрытые члены класса записываются в литеральной нотации:
class A
constructor: (@param) ->
property = 1 # private
method: ->
property + @param
object = new A 1
object.method() # 2
object.property # undefined
Технически, закрытые члены класса, являются обычными локальными переменными:
var A, object;
A = (function() {
var property;
function A(param) {
this.param = param;
}
property = 1;
A.prototype.method = function() {
return property + this.param;
};
return A;
})();
object = new A(1);
object.method();
object.property; # 2
На данный момент реализация закрытых членов весьма ограничена. В частности, закрытые члены класса не доступны для членов определенных вне класса:
class Foo
__private = 1
Foo::method = ->
try
__private
catch error
'undefined'
object = new Foo
object.method() #undefined
Чтобы понять почему так происходит стоит взглянуть на результат трансляции:
var A;
A = (function() {
var __private;
function A() {}
__private = 1;
return Foo;
})();
A.prototype.method = function() {
try {
return __private;
}
catch (error) {
return 'undefined';
}
};
Наверняка у вас уже возник вопрос: почему нельзя поместить определение внешних членов в функцию-конструктор?
На самом деле, это не решит проблему, потому что определение членов класса может находится в разных файлах!
Частично решить эту задачу можно очень простым способом:
class A
constructor: (@value) ->
privated = (param) ->
@value + param
__private__: (name, param...) ->
eval(name).apply @, param if !@constructor.__super__
A::method = ->
@__private__ 'privated', 2
class B extends A
B::method = ->
@__private__ 'privated', 2
object = new A 1
object.method() # 3
object = new B 1
object.method() # undefuned
object.privated # undefuned
Как видите, член класса privated доступно только для членов базового класса!
Все что нам потребовалось так это определить следующий метод в базовом классе:
__private__: (name, param...) ->
eval(name).apply @, param if !@constructor.__super__
К преимуществам данной реализации можно отнести:
+ простота и эффективность
+ легкое внедрение в существующий код
Из недостатков:
— использование функции eval
— лишняя «прослойка» __private__
— в методе __private__ не установлены атрибуты дескриптора контролирующие перечисление этого метода, модификацию и удаление.
Последний пункт относящийся к недостаткам реализации, можно исправить так:
Object.defineProperty @::, '__private__'
value: (name, param...) ->
eval(name).apply @, param if !@constructor.__super__
Теперь метод __private__ не будет перечислен циклом for-of, его нельзя будет модифицировать и удалить. Давайте рассмотрим конечный пример:
class A
constructor: (@value) ->
privated = (param) ->
@value + param
Object.defineProperty @::, '__private__'
value: (name, param...) ->
eval(name).apply @, param if !@constructor.__super__
A::method = ->
@__private__ 'privated', 2
class B extends A
B::method = ->
@__private__ 'privated', 2
object = new A 1
object.method() # 3
object = new B 1
object.method() # undefuned
object.privated # undefuned
i for i of object # 3, value, method
Защищенные члены класса (protected)
Защищённые члены класса доступны только внутри методов базового класса и его наследников.
Формально, в CoffeeScript отсутствует специальный синтаксис для определения защищённых членов класса. Тем не менее, мы можем самостоятельно реализовать подобный функционал:
class A
constructor: (@value) ->
protected = (param) ->
@value + param
__protected__: (name, param...) ->
eval(name).apply @, param
A::method = ->
@__protected__ 'privated', 2
class B extends A
B::method = ->
@__protected__ 'privated', 2
object = new A 1
object.method() # 3
object = new B 1
object.method() # 3
object.protected # undefuned
Как видите, архитектура этого решения практически идентична решению задачи с закрытыми членами класса, и следовательно имеет туже проблему с атрибутами дескриптора. Итоговое решение будет следующее:
Object.defineProperty @::, '__private__'
value: (name, param...) ->
eval(name).apply @, param
Стоит заметить, что это далеко не единственное решение данной задачи, просто оно наиболее универсально для понимания реализации.
Статические члены класса (static)
Статический член класса:
— предваряется символом @ или this
— может существовать только в единственном экземпляре
— для нестатических членов класса доступен только через объект-прототип базового класса
Рассмотрим пример определения статического члена класса:
class A
@method = (param)
-> param
A.method 1 # 1
Теперь давайте рассмотрим возможные (наиболее адекватные) формы записи статического члена класса:
@property: @
@property = @
this.property = @
this.constructor::property = 1
@constructor::property = 1
Class.constructor::property = 1
Если вы заметили, то использование символа @, более универсально.
Обращение к другим нестатическим членам класса возможно только через объект-прототип базового класса:
class A
property: 1
@method: = ->
@::property
do A.method # 1
Обращение к другим статическим членам класса доступно через символ @ или this или имя класса:
class A
@property: 1
@method: = ->
@property + A.property
do A.method # 2
Еще один момент, на который стоит обратить особое внимание, это использование оператора new:
class A
@property: 1
@method: ->
@property
object = new A
object.method() # TypeError: Object # <A> has no method 'method'
Как видите, обращение к несуществующему методу привело к типу ошибки TypeError!
Теперь, давайте рассмотрим корректный способ вызова статического члена класса через экземпляр класса:
class A
@property: 1
@method: ->
@property
object = new A
object.constructor.method() # 1
=> (fat arrow)
Еще одним не маловажным моментом при работе с членами класса, является возможность использовать оператор => (fat arrows), который позволяет не терять контекст вызова.
Например, это может быть полезно для создания функций обратного вызова (callback)
class A
constructor: (@one, @two) ->
method: (three) =>
@one + @two + three
instance = new A 1, 2
object = (callback) ->
callback 3
object instance.method # 6
Того же результата, мы могли бы добиться используя метод call():
class A
constructor: (@one, @two) ->
method: (three) ->
@one + @two + three
instance = new A 1, 2
object = (callback) ->
callback.call instance, 3
object instance.method # 6
Теперь, давайте рассмотрим ситуацию с использованием предиката:
class A
constructor: (@splat...) ->
method: (three) =>
@splat
instance = new A 1, 2, 3, 4, 5
object = (callback, predicate) ->
predicate callback()
object instance.method,
(callback) ->
callback.filter (item) ->
item % 2
# [1, 3, 5]
В этом примере, мы инициализировали создание класса A, с n-м количеством параметров типа Number.
Далее, член класса method вернул массив с переданными в конструктор параметрами. После чего, полученный массив был передан предикату, который отфильтровал значения массива по модулю 2, в результате чего был получен новый массив.
Наследование
Наследование в CoffeScript осуществляется с помощью оператора extends.
Рассмотрим пример:
class A
constructor: (@property) ->
method: ->
@property
class B extends A
object = new B 1
object.method() # 1
Несмотря на то, что в классе B не определены собственные члены класса он наследует их от A.
Теперь, обратите внимание на то, что свойство property также доступно внутри класса B:
class A
constructor: (@property) ->
class B extends A
method: ->
@property
object = new B 1
object.method() # 1
Как видите, суть оператора extends очень проста — установление родственной связи между двумя объектами.
Не сложно догадаться, что оператор extends можно использовать не только с классами:
A = (@param) ->
A::method = (x) ->
@param * x
B = (@param) ->
B extends A
(new B 2).method 2 # 4
К сожалению, официальная документация CoffeeScript довольна «скудна» на примеры, однако вы всегда можете воспользоваться транслятором чтобы посмотреть реализацию того или иного куска кода:
Наиболее важные опции для анализа программного кода:
coffee -c file.coffee # Трансляция .coffee скрипта в JavaScript файл с тем же именем.
coffee -p file.coffee # Вывод на терминал результат трансляции
coffee -e 'console.log i for i in [0..5]' # Интерактивный режим трансляции
coffee -t # Возвращает токены
Очень важную ценность для анализа структуры программы имеет параметр -n (--nodes), он возвращает синтаксическое дерево:
class A
@method: @
class B extends A
do (new B).method
coffee -n
Block
Class
Value "A"
Block
Value
Obj
Assign
Value "this"
Access "method"
Value "this"
Class
Value "B"
Value "A"
Block
Call
Value
Parens
Block
Op new
Value "B"
Access "method"
Наиболее полную информация о синтаксической структуре CoffeeScript смотрите в разделе nodes.coffee официальной документации.
Если определить в классах методы с одинаковыми именами, то родной метод перекроет унаследованный:
class A
constructor: ->
method: -> 'A'
class B extends A
method: -> 'B'
object = new B
object.method() # B
Несмотря на то, что такое поведение вполне ожидаемое, у нас есть возможность вызвать метод класса A из B:
class A
A::method = -> 'A'
class B extends A
B::method = ->
super
object = new B
object.method() # A
Этот код мало чем отличается от предыдущего, за небольшим лишь исключением, что в методе класса B возвращается некий оператор super.
Задача оператора super — вызов свойств определенных в родительском классе и инициализация параметров вызова.
При этом, структура наследования значения не имеет, вызывается метод ближайшего класса в иерархической цепочке наследования:
class A
A::method = -> 'A'
class B extends A
B::method = -> 'B'
class C extends B
C::method = -> super
object = new C
object.method() # B, потому что ближайший метод method определен в классе B
Если метод не определен в прямом родителе, то поиск продолжается следуя по цепочке делегирующих указателей:
class A
A::method = -> 'A'
class B extends A
class C extends B
C::method = -> super
object = new C 1
object.method() # 'A', потому что ближайший метод method определен в классе A
Оператор super, также может принимать параметры:
class A
constructor: (@param) ->
A::method = (x) ->
x + @param
class B extends A
B::method = ->
super 3
object = new B 1
object.method 2 # 4 (3 + 1)
Несмотря на то, что вызывался метод с параметром 2, позже мы переопределили это значение на 3.
Если определить оператор super в методе constructor, то можно переопределить параметры с которыми инициализируется конструктор класса:
class A
constructor: (@param) ->
A::method = (x) ->
x + @param
class B extends A
constructor: (@param) ->
super 3
object = new B 1
object.method 2 # 5 (2 + 3)
Разумеется, допустимо одновременное использование оператора super в членах класса и конструкторе:
class A
constructor: (@param) ->
A::method = (x) ->
x + @param
class B extends A
constructor: (@param) ->
super 3
B::method = (x) ->
super 4
object = new B 1
object.method 2 # 7 (3 + 4)
Теперь, предлагаю подробно рассмотреть внутреннюю реализацию паттерна, который реализует интерфейс наследования:
var A, B, object,
// Ссылка на метод hasOwnProperty
__hasProp = {}.hasOwnProperty;
// Функция __extends первым параметром принимает дочерний класс,
// вторым, родительский, т.е. child наследует свойства от parent
__extends = function(child, parent) {
// Перебор свойств объекта parent
for (var key in parent) {
// Если свойство собственное (не унаследованное)
if (__hasProp.call(parent, key)) {
// Записать в объект child, свойства объекта parent.
// Если свойство с таким именем уже существует,
//то оно перезапишится c новым значением
child[key] = parent[key];
}
}
// Создание промежуточной функции-конструктора
function ctor() {
// Записать в свойство constructor ссылку на объект child
this.constructor = child;
}
// Поменять ссылку прототипа.
// Теперь ссылка будет указывать на прототип объекта parent
ctor.prototype = parent.prototype;
// Установить прототип дочернего объекта на новый объект
child.prototype = new ctor();
// Установить ссылку на родительский прототип
// Используется для реализации оператора super
child.__super__ = parent.prototype;
// вернуть ссылку на
return child;
};
// Родительский конструктор
A = (function() {
function A() {};
return A;
})();
// Дочерний конструктор
B = (function(_super) {
// Вызов функции __extends.
// Первым параметром передается ссылка на объект B,
// вторым - ссылка на родительский объект A
__extends(B, _super);
function B() {
// Вызвать родительский конструктор A в контексте конструктора B
// Равносильно A.apply(this, arguments);
return B.__super__.constructor.apply(this, arguments);
}
return B;
})(A);
Подведем итоги:
— Классы позволяют более четко визуализировать структуру обобщённых логически связанных сущностей;
— Наличие классов разрешает некоторое недопонимание при работе с объектами и наследованием, особенно у тех кто работал с классами ранее;
— Несмотря на то, что в ECMAScript не определены модификаторы уровня доступа (public, private и protected) их можно реализовать самостоятельно.
— Внутренняя реализация классов очень проста;
Дополнительная литература
Тонкости ECMA-262-3. Часть 7.1. ООП: Общая теория
Тонкости ECMA-262-3. Часть 7.2. ООП: Реализация в ECMAScript
Прототипное программирование
Объектно-ориентированное программирование
Наследование
Полиморфизм
Инкапсуляция
Делегирование
PS: На самом деле, некоторое время я даже сомневался в целесообразности написания этой темы, поскольку работа с классами в CoffeeScript реализована настолько просто — насколько это возможно.
Автор: monolithed