Секретная жизнь объектов
Оригинал: The Secret Life of Objects
Абстрактные типы данных реализуются с помощью специальной программы, которая определяет тип в соответствии с операциями, которые могут быть выполнены над ним.
Barbara Liskov, «Programming with Abstract Data Types»
В четвёртой главе мы познакомились с объектами JavaScript (object). В культуре программирования существует понятие объектно-ориентированного программирования (OOP), которое представляет собой набор технологий, использующих объекты (и связанные концепции) в качестве основного принципа организации программ.
Хотя нет единого мнения о его точном определении, объектно-ориентированное программирование стало основой для разработки многих языков программирования, включая JavaScript. В этой главе мы рассмотрим, как эти идеи применяются в JavaScript.
Инкапсуляция
Основная идея объектно-ориентированного программирования заключается в разделении программы на небольшие фрагменты, каждый из которых отвечает за управление своим состоянием.
Таким образом, знания о работе некоторых фрагментов программы могут быть сохранены локально. Люди, занимающиеся другими аспектами работы, не должны помнить или даже знать об этих знаниях. Независимо от того, когда эти локальные детали изменяются, необходимо только обновить код вокруг них.
Эти фрагменты программы взаимодействуют через интерфейсы (interface), функции или ограниченное множество связанных методов, предоставляя полезные функции на более абстрактном уровне и скрывая их точную реализацию.
Эти программные фрагменты моделируются с использованием объектов. Их интерфейс состоит из набора определённых методов (method) и свойств (property). Часть свойств интерфейса называется общедоступной (public). Другие внешние коды не должны обращаться к свойствам, называемым частными (private).
Многие языки предоставляют способы различения общедоступных и частных свойств и полностью предотвращают доступ внешних кодов к частным свойствам. JavaScript снова использует минималистский подход, но не имеет его. По крайней мере, пока — есть работа, которая добавит его в язык.
Даже если в языке нет встроенной дифференциации, разработчики JavaScript успешно использовали эту идею. Обычно доступный интерфейс описывается в документации или цифровом формате. Имя свойства часто начинается с символа подчёркивания (_), чтобы указать, что это частное свойство.
Разделение интерфейса и реализации является хорошей практикой. Это обычно называют инкапсуляцией (encapsulation).
Методы
Метод — это просто свойство, содержащее значение функции. Вот простой метод:
let rabbit = {};
rabbit.speak = function(line) {
console.log(`The rabbit says '${line}'`);
};
rabbit.speak("I'm alive.");
// → The rabbit says 'I'm alive.'
Обычно методы выполняют некоторые операции при вызове объекта. Когда функция вызывается как метод объекта, она находит соответствующее свойство в объекте и вызывает его напрямую. Когда функция используется в качестве метода вызова, значение this, связанное с ней, автоматически указывает на объект, который её вызвал.
function speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
let whiteRabbit = {type: "white", speak: speak};
let fatRabbit = {type: "fat", speak: speak};
whiteRabbit.speak("Oh my ears and whiskers, " +
"how late it's getting!");
// → The white rabbit says 'Oh my ears and whiskers, how
// late it's getting!'
hungryRabbit.speak("I could use a carrot right now.");
// → The hungry rabbit says 'I could use a carrot right now.'
Вы можете рассматривать this как дополнительный параметр, передаваемый разными способами. Если вы хотите явно передать его, вы можете использовать метод call функции, который принимает значение this в качестве первого параметра и обрабатывает остальные как обычные параметры.
speak.call(hungryRabbit, "Burp!");
// → The hungry rabbit says 'Burp!'
Этот код использует ключевое слово this для вывода типа говорящего кролика. Вспомним методы apply и bind, которые принимают первый параметр для имитации вызова метода объекта. Эти методы копируют первый параметр в this.
Поскольку каждая функция имеет свою собственную привязку this, её значение зависит от способа вызова, поэтому в обычных функциях, определённых с помощью ключевого слова function, нельзя ссылаться на внешнее this области видимости.
Стрелочные функции отличаются — они не связывают свои собственные this, но могут видеть this привязки своей окружающей (определяющей) области видимости. Таким образом, вы можете ссылаться на this в локальной функции следующим образом:
function normalize() {
console.log(this.coords.map(n => n / this.length));
}
normalize.call({coords: [0, 2, 3], length: 5});
// → [0, 0.4, 0.6]
Если бы я использовал ключевое слово function для ввода параметров в map, код не работал бы.
Прототип
Давайте внимательно посмотрим на следующий код.
let empty = {};
console.log(empty.toString);
// → function toString(){…}
console.log(empty.toString());
// → [object Object]
Я извлёк атрибут из пустого объекта. Удивительно!
На самом деле это не так. Я просто скрыл некоторые внутренние рабочие детали объектов JavaScript. Каждый объект содержит прототип (prototype) помимо своих собственных атрибутов. Прототип — это другой объект, источник атрибутов объекта. Когда разработчик обращается к атрибуту, которого нет у объекта, он ищет атрибут в прототипе объекта, затем в прототипе прототипа и так далее.
Итак, каков прототип пустого объекта? Это Object.prototype, который является родительским прототипом всех объектов.
console.log(Object.getPrototypeOf({}) ==
Object.prototype);
// → true
console.log(Object.getPrototypeOf(Object.prototype));
// → null
Как вы и предполагали, Object.getPrototypeOf возвращает прототип объекта.
Отношения прототипов объектов JavaScript представляют собой древовидную структуру, где весь корень дерева — это Object.prototype. Object.prototype предоставляет некоторые методы, которые можно использовать во всех объектах. Например, метод toString может преобразовать объект в строковое представление.
Многие объекты не используют непосредственно Object.prototype в качестве своего прототипа, а используют другой прототипный объект для предоставления ряда различных атрибутов по умолчанию. Функции наследуются от Function.prototype, а массивы — от Array.prototype.
console.log(Object.getPrototypeOf(Math.max) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf([]) ==
Array.prototype);
// → true
Для таких прототипных объектов их собственный прототип также является другим прототипным объектом, обычно Object.prototype, так что можно сказать, что эти прототипные объекты могут косвенно предоставлять такие методы, как toString.
Вы можете использовать Object.create для создания объекта с определённым прототипом.
let protoRabbit = {
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
};
let killerRabbit = Object.create(protoRabbit);
killerRabbit.type = "killer";
killerRabbit.speak("SKREEEE!");
// → The killer rabbit says 'SKREEEE!'
Атрибуты, подобные speak(line), являются сокращением для определения методов. Он создаёт атрибут с именем speak и предоставляет функцию в качестве значения. Прототип объекта protoRabbit — это контейнер, который содержит все общие свойства объектов кроликов.
Каждый отдельный объект кролика (например, killerRabbit) может содержать свои собственные свойства (например, тип в этом примере), а также наследовать общие свойства от своего прототипа.
Классы
Систему прототипов JavaScript можно рассматривать как неформальную реализацию концепции классов, которая является основой объектно-ориентированного программирования. Класс определяет форму объекта — его методы и свойства. Такие объекты называются экземплярами класса.
Прототипы полезны для свойств. Все экземпляры одного класса разделяют одни и те же значения свойств, такие как методы. Каждое свойство экземпляра, например, атрибут типа нашего кролика, должно быть сохранено непосредственно в самом объекте.
Таким образом, чтобы создать экземпляр заданного класса, необходимо, чтобы объект был получен из правильного прототипа, но также необходимо убедиться, что он обладает всеми свойствами, которые должен иметь экземпляр этого класса. Это роль конструктора (конструкторской функции).
function makeRabbit(type) {
let rabbit = Object.create(protoRabbit);
rabbit.type = type;
return rabbit;
}
JavaScript предоставляет способ упростить определение такого рода функций. Если ключевое слово new используется перед вызовом функции, функция будет рассматриваться как конструктор. Это означает, что будет создан объект с правильным прототипом, привязан к this внутри функции и возвращён после завершения функции.
Конструктор использует прототип объекта, который можно найти через свойство prototype конструктора.
function Rabbit(type) {
this.type = type;
}
Rabbit.prototype.speak = function(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
};
let weirdRabbit = new Rabbit("weird");
Все функции автоматически получают свойство с именем prototype, которое по умолчанию содержит обычный пустой объект, унаследованный от Object.prototype. Этот объект можно заменить новым объектом, если это необходимо. Или вы можете добавить свойства к существующему объекту, как показано в примере.
По соглашению имена конструкторов пишутся с заглавной буквы, чтобы их было легко отличить от других функций.
Важно понимать разницу между способом связи конструктора с его прототипом через свойство prototype и способом, которым объекты имеют прототип, который можно получить через Object.getPrototypeOf. Прототипом конструктора является Function.prototype, поскольку конструктор является функцией. Свойство prototype этого конструктора содержит прототип, используемый для создания экземпляров.
console.log(Object.getPrototypeOf(Rabbit) ==
Function.prototype);
// → true
console.log(Object.getPrototypeOf(weirdRabbit) ==
Rabbit.prototype);
// → true
Представление класса
Итак, класс JavaScript — это конструктор с прототипом. Это то, как они работают, и так вы их пишете до 2015 года. Недавно у нас появился более удобный синтаксис.
class Rabbit {
constructor(type) {
this.type = type;
}
speak(line) {
console.log(`The ${this.type} rabbit says '${line}'`);
}
}
let killerRabbit = new Rabbit("killer");
let blackRabbit = new Rabbit("black");
Ключевое слово class начинает объявление класса и позволяет определить конструктор и набор методов в одном месте. В фигурных скобках объявления класса можно написать любое количество методов. Объект с именем constructor обрабатывается особым образом. Он обеспечивает фактический конструктор, который будет привязан к имени «Rabbit». Другие функции упакованы в прототип этого конструктора. Таким образом, приведённое выше объявление класса эквивалентно определению конструктора из предыдущего раздела. Оно выглядит лучше.
В настоящее время классы позволяют добавлять только методы (функции) в прототип. Когда вы хотите сохранить нефункциональное значение в прототипе, это может быть неудобно. Возможно, в следующей версии языка это будет улучшено. Сейчас вы можете манипулировать прототипом после определения класса для создания этих свойств.
Подобно функции, класс можно использовать в выражениях и операторах. При использовании в качестве выражения оно не определяет привязку, а просто создаёт конструктор как значение. Вы можете опустить имя класса в выражении класса.
let object = new class { getWord() { return "hello"; } };
console.log(object.getWord());
// → hello
Перекрытие унаследованных свойств
При добавлении свойств к объекту независимо от того, существуют ли они уже в прототипе, они добавляются к самому объекту. Если в прототипе уже есть одноимённое свойство, оно больше не влияет на объект, потому что оно скрыто за собственным свойством объекта.
Rabbit.prototype.teeth = "small";
console.log(killerRabbit.teeth);
// → small
killerRabbit.teeth = "long, sharp, and bloody";
console.log(killerRabbit.teeth);
// → long, sharp, and bloody
console.log(blackRabbit.teeth);
// → small
console.log(Rabbit.prototype.teeth);
// → small
На рисунке ниже показано состояние после выполнения кода. Rabbit и Object являются прототипами killerRabbit, и мы можем найти в них свойства, которых нет в killerRabbit.
Перекрытие существующих свойств в прототипах полезно. Как показано в примере, мы перекрыли зубы killerRabbit, что можно использовать для описания особых свойств экземпляра (объекта, являющегося экземпляром более общего класса), сохраняя при этом возможность получения стандартных значений из простого объекта из прототипа.
Перекрытие также используется для предоставления методов toString стандартным функциям и массивам, в отличие от прототипов объектов.
console.log(Array.prototype.toString ==
Object.prototype.toString);
// → false
console.log([1, 2].toString());
// → 1,2
Вызов метода toString массива приводит к результату, аналогичному вызову .join(",") для массива, то есть вставляет запятую между каждым значением. Однако прямое использование массива для вызова Object.prototype.toString приведёт к созданию совершенно другой строки. Поскольку метод toString объекта не знает о структуре массива, он просто выводит пару квадратных скобок, за которыми следуют слова «объект» и имя типа.
console.log(Object.prototype.toString.call([1, 2]));
// → [object Array]
Отображение
Мы видели термин «отображение» в предыдущей главе, относящийся к операции преобразования структуры данных путём применения функции к элементам. Интересно, что одно и то же слово используется для разных вещей в программировании.
Отображение (существительное) — это структура данных, связывающая значения (ключи) с другими значениями. Например, вы можете захотеть сопоставить имена с возрастом. Для этого можно использовать объект.
let ages = {
Boris: 39,
Liang: 22,
Júlia: 62
};
console.log(`Júlia is ${ages["Júlia"]}`);
// → Júlia is 62
console.log("Is Jack's age known?", "Jack" in ages);
// → Is Jack's age known? false
console.log("Is toString's age known?", "toString" in ages);
// → Is toString's age known? true
Здесь атрибуты объекта являются именами людей, а значения атрибутов — их возрастом. Но, конечно, у нас нет записи о возрасте кого-либо по имени toString в нашем отображении. Это потому, что простые объекты наследуются от Object.prototype, поэтому они выглядят так, как будто у них есть этот атрибут.
Поэтому использование простых объектов в качестве отображения опасно. Есть несколько способов избежать этой проблемы. Во-первых, можно использовать null для создания объектов. Если вы передадите null в Object.create, полученный объект не будет наследоваться от Object.prototype и может безопасно использоваться в качестве отображения.
console.log("toString" in Object.create(null));
// → false
Атрибуты объекта должны быть строками. Если вам нужно отображение, ключи которого нельзя легко преобразовать в строку — например, объект — вы не можете использовать объект в качестве своего отображения.
К счастью, JavaScript имеет класс с именем Map, предназначенный именно для этой цели. Для выполнения перевода необходимо уточнение запроса.
Перевод исходного текста без учёта этого уточнения:
Метод next
сначала проверяет, достигнут ли нижний предел матрицы. Если нет, то сначала создаётся объект для сохранения текущего значения, затем его позиция обновляется и, при необходимости, он перемещается на следующую строку.
Давайте сделаем класс Matrix
итерируемым. В этой книге я иногда буду использовать постфактум операции прототипирования для добавления методов в классы, чтобы сделать отдельные фрагменты кода меньше и независимее. В обычной программе не нужно разбивать код на мелкие части, а просто объявлять эти методы прямо в классе.
Matrix.prototype[Symbol.iterator] = function() {
return new MatrixIterator(this);
};
Теперь мы можем перебирать матрицу с помощью for/of
.
let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`);
for (let {x, y, value} of matrix) {
console.log(x, y, value);
}
// → 0 0 value 0,0
// → 1 0 value 1,0
// → 0 1 value 0,1
// → 1 1 value 1,1
Обычно интерфейсы состоят из методов, но они также могут содержать атрибуты, которые не являются функциями. Например, у объекта Map
есть атрибут size
, который сообщает вам, сколько ключей хранится внутри.
Такие объекты даже не должны вычислять и хранить такие атрибуты непосредственно в экземпляре. Даже напрямую доступные атрибуты могут скрывать вызовы методов. Этот метод называется считывателем (getter), и они определяются путём написания get
перед именем метода.
let varyingSize = {
get size() {
return Math.floor(Math.random() * 100);
}
};
console.log(varyingSize.size);
// → 73
console.log(varyingSize.size);
// → 49
Каждый раз, когда кто-то читает атрибут size
этого объекта, вызывается соответствующий метод. Вы можете сделать что-то подобное, когда вы записываете в атрибут с помощью записывателя (setter).
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
get fahrenheit() {
return this.celsius * 1.8 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) / 1.8;
}
static fromFahrenheit(value) {
return new Temperature((value - 32) / 1.8);
}
}
let temp = new Temperature(22);
console.log(temp.fahrenheit);
// → 71.6
temp.fahrenheit = 86;
console.log(temp.celsius);
// → 30
Класс Temperature
позволяет вам читать и записывать температуру в градусах Цельсия или Фаренгейта, но внутренне он хранит только градусы Цельсия и автоматически преобразует их в градусы Фаренгейта в считывателе fahrenheit
.
Иногда вы хотите добавить некоторые атрибуты непосредственно к вашему конструктору, а не к прототипу. Эти методы не будут доступны для экземпляров класса, но могут использоваться для создания экземпляров.
Внутри объявления класса методы, перед которыми стоит ключевое слово static
, хранятся в конструкторе. Таким образом, класс Temperature
может позволить вам написать Temperature.fromFahrenheit(100)
для создания температуры с использованием температуры по Фаренгейту.
Известно, что некоторые матрицы симметричны. Если вы перевернёте симметричную матрицу по диагонали от левого верхнего угла до правого нижнего, она останется неизменной. Другими словами, значение, хранящееся в x,y
, всегда совпадает со значением в y,x
.
Представьте себе, что нам нужна структура данных, подобная Matrix
, но которая обязательно гарантирует, что матрица симметрична. Мы могли бы написать это с нуля, но это потребовало бы повторения некоторого кода, который очень похож на код, который мы уже написали.
Система прототипов JavaScript позволяет создавать новый класс, похожий на старый класс, но с некоторыми атрибутами, определёнными по-новому. Новый класс наследуется от прототипа старого класса, но метод set
получает новое определение.
В терминах объектно-ориентированного программирования это называется наследованием (inheritance). Новый класс наследует свойства и поведение старого класса.
class SymmetricMatrix extends Matrix {
constructor(size, element = (x, y) => undefined) {
super(size, size, (x, y) => {
if (x < y) return element(y, x);
else return element(x, y);
});
}
set(x, y, value) {
super.set(x, y, value);
if (x != y) {
super.set(y, x, value);
}
}
}
let matrix = new SymmetricMatrix(5, (x, y) => `${x},${y}`);
console.log(matrix.get(2, 3));
// → 3,2
Ключевое слово extends
используется для обозначения того, что этот класс не должен быть основан непосредственно на стандартном прототипе Object
, а скорее на другом классе. Это называется суперклассом (superclass). Подкласс (subclass) — это производный класс.
Чтобы инициализировать экземпляр SymmetricMatrix
, конструктор вызывает конструктор своего суперкласса через super
. Это необходимо, потому что если это новое поведение объекта (примерно) похоже на Matrix
, ему нужна матрица с поведением экземпляра. Чтобы гарантировать, что матрица является симметричной, конструктор оборачивает метод content
, чтобы поменять местами координаты значений ниже диагонали.
set
снова использует super
, но на этот раз он не вызывает конструктор, а вызывает определённый метод из набора методов суперкласса. Мы переопределяем set
, но хотим использовать старое поведение. Поскольку this.set
ссылается на новый метод set
, вызов этого метода не будет работать. Внутри классовых методов super
предоставляет способ вызова метода, определённого в суперклассе.
Наследование позволяет нам создавать немного разные типы данных с относительно небольшим объёмом работы, начиная с существующих типов данных. Это фундаментальная часть объектно-ориентированной традиции, наряду с инкапсуляцией и полиморфизмом. Хотя последние два теперь широко признаны великими идеями, наследование более спорно.
Хотя инкапсуляция и полиморфизм можно использовать для разделения кода друг от друга, тем самым уменьшая сцепление всей программы, наследование фактически связывает классы вместе, создавая больше сцепления. При наследовании класса вы обычно должны знать о нём больше, чем если бы вы просто использовали его. Наследование может быть полезным инструментом, и я использую его сейчас в своих программах, но оно не должно быть вашим первым инструментом, и вы, возможно, не должны активно искать возможности для построения иерархии классов (семейного древа классов).
instanceof
Иногда полезно знать, происходит ли объект от определённого класса. JavaScript предоставляет бинарный оператор под названием instanceof
.
console.log(
new SymmetricMatrix(2) instanceof SymmetricMatrix);
// → true
console.log(new SymmetricMatrix(2) instanceof Matrix);
// → true
console.log(new Matrix(2, 2) instanceof SymmetricMatrix);
// → false
console.log([1] instanceof Array);
// → true
Этот оператор просматривает всю иерархию наследования. Поэтому SymmetricMatrix
является экземпляром SymmetricMatrix
. Этот оператор также может применяться к стандартным конструкторам, таким как Array
. Почти каждый объект является экземпляром Object
.
Объекты не только содержат свои собственные атрибуты. У объектов есть ещё один объект: прототип, и пока прототип содержит атрибуты, все объекты, созданные из этого прототипа, также могут считаться содержащими соответствующие атрибуты. Простые объекты напрямую используют Object.prototype
в качестве прототипа.
Конструктор — это функция, обычно начинающаяся с заглавной буквы, которую можно использовать с оператором new
для создания нового объекта. Прототип нового объекта — это свойство prototype
конструктора. Используя размещение атрибутов в их прототипах, это можно эффективно использовать, предоставляя общие атрибуты всем значениям данного типа. class
обеспечивает явный способ определения конструктора и его прототипа.
Вы можете определить считыватель, который тайно вызывает метод при каждом доступе к атрибуту объекта. Статический метод — это метод, хранящийся в конструкторе класса, а не в его прототипе.
Для данного объекта и конструктора оператор instanceof
может сказать вам, является ли объект экземпляром конструктора.
Можно использовать объекты для полезной цели, указав интерфейс для них, сообщая каждому, что они могут взаимодействовать с объектами только через этот интерфейс. Детали реализации объекта теперь скрыты за интерфейсом.
Более одного типа могут реализовывать один и тот же интерфейс. Код, написанный для использования интерфейса, автоматически знает, как использовать любое количество различных объектов, предоставляющих интерфейс. Это называется полиморфизмом.
Если вы хотите создать несколько классов, которые отличаются только в некоторых деталях, лучше всего создать новый класс как подкласс существующего класса, унаследовав часть его поведения. Добавление методов plus
и minus
к прототипу Vec
и атрибута length
:
Добавить к прототипу Vec два метода: plus и minus, которые принимают другой вектор в качестве параметра и возвращают сумму векторов (this и параметр) и разность векторов соответственно. Также добавить атрибут length, который будет использоваться для вычисления длины вектора, то есть расстояния между точкой (x, y) и началом координат (0, 0).
// Ваш код здесь.
console.log(new Vec(1, 2).plus(new Vec(2, 3))); // → Vec{x: 3, y: 5}
console.log(new Vec(1, 2).minus(new Vec(2, 3))); // → Vec{x: -1, y: -1}
console.log(new Vec(3, 4).length); // → 5
Создание класса Group:
Создать класс Group, который имеет методы add, delete и has. Метод add добавляет значение в группу, но только если оно не является её членом. Метод delete удаляет значение из группы, если оно является её членом. Метод has возвращает логическое значение, указывающее, является ли его параметр членом группы. Также предоставить статический метод from, который принимает итерируемый объект в качестве аргумента и создаёт группу, содержащую все значения, полученные при итерации по этому объекту.
class Group {
// Ваш код здесь.
}
let group = Group.from([10, 20]);
console.log(group.has(10)); // → true
console.log(group.has(30)); // → false
group.add(10);
group.delete(10);
console.log(group.has(10)); // → false
Итерация по группе:
Сделать класс Group итерируемым. Если вы используете массив для представления членов группы, не просто возвращайте итератор, вызывая метод Symbol.iterator массива. Это будет работать, но это нарушит цель этого упражнения.
Если группа изменяется во время итерации, ваш итератор может вести себя странно. Это нормально.
for (let value of Group.from(["a", "b", "c"])) {
console.log(value);
}
// → a
// → b
// → c
Метод hasOwnProperty:
Когда вы хотите игнорировать свойства прототипа, объектный метод hasOwnProperty можно использовать как более мощную замену оператору in. Однако что делать, если вашему отображению нужно содержать слово hasOwnProperty? Вы не сможете вызвать этот метод снова, потому что свойство объекта скрывает значение метода. Можете ли вы придумать способ вызова hasOwnProperty для объектов с собственным одноимённым свойством?
let map = {one: true, two: true, hasOwnProperty: true};
// Исправить этот вызов
console.log(map.hasOwnProperty("one"));
// → true
Вы можете оставить комментарий после Вход в систему
Неприемлемый контент может быть отображен здесь и не будет показан на странице. Вы можете проверить и изменить его с помощью соответствующей функции редактирования.
Если вы подтверждаете, что содержание не содержит непристойной лексики/перенаправления на рекламу/насилия/вульгарной порнографии/нарушений/пиратства/ложного/незначительного или незаконного контента, связанного с национальными законами и предписаниями, вы можете нажать «Отправить» для подачи апелляции, и мы обработаем ее как можно скорее.
Опубликовать ( 0 )