Знакомство с примитивными объектами в JavaScript (первая часть)
Пытаясь переписать часть разрозненной кодовой базы, Кирилл заново открыл для себя силу объектов JavaScript. Он снова и снова возвращался к шаблону, который сохранял ясность кода и убирал ненужные операции. Приближение обычных объектов к примитивным значениям изменило ситуацию. Так были открыты Примитивные объекты. В первой части вы увидите, чем отличаются объекты и примитивные значения и как их сблизить.
Кажется, естественным использовать строки для различения вещей. Вполне вероятно, что в вашей кодовой базе есть объекты со свойствами name
, id
или label
, используемые для определения того, является ли объект тем, который вы ищете.
if (element.label === "title") {
make_bold(element);
}
В определённый момент ваш проект растёт (в размере, важности, популярности или во всём сразу). Ему требуется больше строк, поскольку появляется больше вещей, которые нужно отличать друг от друга. Строки становятся длиннее, как и стоимость опечаток или, скажем, изменения соглашения об именовании переменных. Теперь вам нужно найти все экземпляры этих строк и заменить их. Следовательно, коммит для этого изменения становится намного больше, чем должен быть. Это позволяет вам выглядеть лучше в глазах несведущих. Одновременно это делает вашу жизнь несчастной, поскольку теперь гораздо труднее найти причину регрессии в истории git.
Строки плохо подходят для идентификации. Вы должны учитывать уникальность и опечатки; ваш редактор или IDE не проверит, та ли это строка, которую вы имели в виду. Это плохо. Я слышу, как кто-то говорит: Просто поместите их в переменную, да
. Это хорошее предложение, и оно снимает часть моих опасений. Но посмотрите на Джона Смита:
const john_smith_a_person = " John Smith";
const john_smith_a_company = " John Smith";
// Есть ли в них одинаковые имена?
john_smith_a_person === john_smith_a_company; // true
// Являются ли они одним и тем же?
john_smith_a_person === john_smith_a_company; // true
Случилось так, что Джон носит имя одной компании. Что если я скажу вам, что у меня есть лучшее решение? То, которое устранит все проблемы и добавит больше ценности — позволит вам достичь большего. Что бы вы ответили? Ну, я не буду переписывать статью только потому, что ваш ответ не вписывается в моё изложение. Ответ — объекты. Вы используете сами объекты, чтобы понять, является ли объект тем, что вы ищете.
// Есть ли в них одинаковые имена?
john_smith_a_person.name === john_smith_a_company.name; // true
// Являются ли они одним и тем же?
john_smith_a_person === john_smith_a_company; // false
Это делает намерение более ясным. Позвольте мне привести пример получше. Допустим, в вашем приложении есть метки. Они локализованы, поэтому строка метки определяется библиотекой локализации, которую вы используете, и процессом перевода вашей команды. Вы храните свои метки в модуле, где все они аккуратно организованы и курируются. Когда нужно сделать что-то особенное для определённых меток, вы можете сравнить их непосредственно с теми, что у вас есть.
import React from "react";
import labels from "./labels.js";
const render_label(label) => (
<Label
className={label === labels.title ? "bold" : "plain"}
icon={label.icon}
text={label.text}
/>
)
function TableOfContents({ items }) {
return (
<ul className="my-menu">
{items.map(render_label(item.label)}
</ul>
);
}
Видите, как много ещё можно сделать с объектами? В модуле labels
я выделил метку title
, которая в данном случае должна быть выделена жирным шрифтом. Кроме того, будучи объектом, моя метка может содержать строку локализации (образно называемую text
) и иконку. Все это аккуратно организовано заранее, что сохраняет чистоту логики пользовательского интерфейса.
Но это лишь часть картины. Я знаю, что мы используем объекты повсюду, и нет ничего нового в том, чтобы группировать в них вещи. Но я готов поспорить, что вы не используете их именно так. Я редко вижу, чтобы два объекта сравнивали подобным образом, потому что вы никогда не знаете, что там внутри и откуда оно взялось. Объекты постоянно создаются и изменяются. Скорее всего, их будут сравнивать по значениям свойств, чем по самим объектам. И причина этого в том, что объекты не подходят для такого использования. Они слишком функциональны. Чтобы разрешить этот и многие другие случаи использования, нам придётся, с одной стороны, уменьшить некоторые возможности объектов, а с другой — реализовать ещё несколько. И в итоге мы получим то, что я называю Примитивные объекты. Это… решение… некоторых проблем.
В первой части серии я хочу рассказать о некоторых аспектах JavaScript, помогающих приблизить объекты к примитивным значениям, что в свою очередь позволит нам воспользоваться общими возможностями языка, которые обычно не ассоциируются с объектом, например, сравнением и арифметическими операторами. В следующей части мы подробно рассмотрим практические примеры и инструменты для работы с такими объектами. Теперь давайте посмотрим, что представляют собой объекты в JavaScript.
Свойства примитивных значений, которые нам необходимы
Во-первых, давайте определим нашу цель. Давайте нарисуем картину того, где мы хотели бы оказаться впоследствии. Какими свойствами примитивных значений мы хотим, чтобы обладали наши объекты?
- Иммутабельность. Примитивные значения доступны только для чтения. Мы хотим, чтобы наши объекты не могли быть отредактированы никем после их создания. Вспомните предыдущий пример. Что толку от метки, если какой-то неподконтрольный нам код изменил её текст или иконку? Как только объект определён, он должен быть создан.
- Работа с операторами. Выражения с определёнными операторами возвращают соответствующий тип. Арифметические операторы возвращают числа. Операторы сравнения возвращают логические значения.
- Имеют литеральный синтаксис. Литералы для примитивов дают точное значение или, скорее, объект, представляющий это значение. Такие объекты создаются один раз для каждого значения. Каждый раз, когда в вашем коде появляется
hello
, вы получаете один и тот же объект. - Имеют типы. Оператор
typeof
говорит вам, с чем вы имеете дело (за исключениемnull
). Мы не всегда знаем, какой тип объекта мы получаем. Поэтому, прежде чем тыкать в его свойства, было бы неплохо знать, с чем мы имеем дело.
Я перечислил их по степени непосредственной полезности. И, как повезёт, они также упорядочены по лёгкости получения. В этой статье я расскажу о первом и части второго. Мы увидим, как сделать объекты неизменяемыми/иммутабельными. Мы также определим их представление в примитивных значениях, что позволит использовать некоторые операторы над ними. Переход от объектов к примитивным значениям прост, так как примитивные значения сами являются объектами — вроде как.
Это объекты до самого низа, даже если это вроде как не так
Я помню своё замешательство, когда впервые увидел {} === {}; // false
. Что это за язык, который даже не может различить две одинаковые вещи? Это казалось таким нелепым и забавным. Гораздо позже я узнал, что в JavaScript есть детали гораздо хуже, после чего перестал смеяться, смотря wat talk.
Объект — это одна из фундаментальных вещей в JavaScript. Возможно, вы слышали, что в JavaScript всё является объектом. Это действительно так. За исключением некоторых нижних значений, все примитивы являются объектами. Хотя технически это более тонко, с точки зрения нашего кода это правда. На самом деле, это достаточно верно, чтобы считать, что всё является объектами, может быть полезной ментальной моделью. Но давайте сначала попробуем понять, что происходит с этим сравнением объекта с объектом, которое так забавляло младшего меня.
Синтаксис Объектный литерал используется для создания новых объектов. Он позволяет нам объявить и инициировать объект в одном выражении.
// Вместо этого.
const my_object = new Object();
my_object.first_property = "First property";
my_object.nth_property = "Next property";
// Вы можете сделать так.
const my_object = {
first_property: "First property",
nth_property: "Next property"
};
Намного чище, верно? Но теперь я думаю, что отсутствие строки инициализации объекта — это то, что заставило меня запутаться в этих двух пустых выражениях равенства объектов. Казалось, что оно показывает, что язык пытается распознать кажущееся равенство. Но на самом деле в этом выражении происходит следующее:
new Object() === new Object(); // false
Теперь очевидно, что они не равны. Вы сравниваете два разных объекта, которые вы только что создали. Ожидать обратного — то же самое, что ожидать, что 5 === 3
вернёт истину
. В обоих случаях это разные вещи.
Давайте проведём проверку на разумность. Будут ли две переменные, ссылающиеся на один и тот же объект, считаться равными?
const my_object = {};
const other_thing = my_object;
my_object === other_thing; // true
В данном случае только в первой строке есть выражение, создающее объект. Во второй строке мы заставляем переменную other_thing
ссылаться на только что созданный объект. Теперь две переменные ссылаются на один и тот же объект. Сравнивать их — всё равно что сравнивать два одинаковых числа, не так ли?
Почему это важно? Потому что это даёт нам возможность проверить, относится ли переменная к искомому объекту. И если мы подумаем об этом в контексте всё есть объект
, то именно так работают числа и строки. Когда вы сравниваете две переменные, содержащие строки, движку не нужно проверять, одинаков ли каждый символ в этих строках. Достаточно сравнить, ссылаются ли переменные на один и тот же объект. Это происходит благодаря самому существенному различию между обычными объектами и примитивными значениями — иммутабельности.
Как приблизить обычные объекты к примитивным значениям
В JavaScript примитивные значения неизменяемы. Вы не можете изменить ни одного символа в строке, так же как не можете сделать так, чтобы число пять превратилось в шесть. Если вы используете const
для инициализации переменной и поместите в неё примитивное значение, оно всегда будет оставаться неизменным. Никто не сможет изменить значение; оно неизменяемо. Никто не сможет переназначить переменную; она была создана с помощью const
.
Давайте рассмотрим, как работают числа. Вы можете получить шесть из пяти, увеличив его на единицу, но это ничего не изменит в числе пять.
const five = 5;
const six = 5 + 1;
five === 5; // true
Кто-то может сказать, что использование let
изменит это. Но посмотрите, оно не может изменить пять:
const five = 5;
let result = 5;
result++;
result === 6; // true
five === 5; // true
Пять — это всё равно пять. Это потому, что ++
— просто сокращение для += 1
. Видите знак равенства? Произошло то, что я присвоил новое значение переменной result
, значение, которое я получил из выражения result + 1
(именно для этого += 1
является сокращением). Ключевое слово const
предотвращает переназначение переменной. В приведённом выше примере именно оно даёт мне возможность знать, что five
всегда ссылается на объект 5
.
Можно предположить, что единственный способ изменения примитивных значений в JavaScript — это присваивание. Что означает, что на самом деле мы изменяем то, на что ссылается переменная. Таким образом, изменяются именно переменные, а не значения. По крайней мере, не примитивные. Но как это работает с объектами?
После инициализации объекта вы можете изменить его свойства: удалить их, добавить новые и переназначить старые. Все мы знаем, как это делается. Но кроме этого, он ведёт себя так же, как и примитивные значения. На самом деле, если вы привыкнете к модели, в которой объекты и примитивные значения — это одно и то же, вы будете по-другому смотреть на всевозможные проблемы в JavaScript.
Вероятно, вы столкнулись с вопросом о том, как переменные передаются в функцию. Люди спрашивают, передаются ли переменные по значению или по ссылке. Обычный ответ — примитивные значения передаются по значению, а объекты передаются по ссылке. Но с той ментальной моделью, которую я навязываю здесь, вы, возможно, уже знаете, что я скажу по этому поводу. Перед этим позвольте мне показать вам, как этот вопрос не имеет особого смысла в JavaScript. Я также раскрою вам хитрость, которую используют многие статьи и учебники.
Когда вы передаёте переменные в качестве параметров вызова функции, они присваиваются аргументам функции. Аргументы являются локальными переменными в области видимости функции и не имеют обратной связи с исходными переменными, что вполне логично. Если вы передаёте выражение в функцию, вам нужно куда-то поместить его результат, не так ли?
Посмотрите на следующие две функции. Они делают одно и то же — передают значение, но одна из них определена с одним параметром, а другая — без. Вторая демонстрирует, что происходит с параметром, который мы передали.
function single(arg) {
return arg;
}
function none() {
// Первый параметр присваивается переменной `arg`.
// Обратите внимание на `let`; это будет важно позже.
let arg = arguments[0];
return arg;
}
single("hi"); // "hi"
none(5); // 5
Вы видите, что они оба работают одинаково. Помня о том, как работают аргументы функции, давайте попробуем изменить некоторые значения. У нас будет функция, которая изменяет свой единственный аргумент и возвращает его. Я также создам несколько переменных, которые буду передавать в функцию по очереди. Попробуйте предсказать, что будет выведено в консоль. (Ответ находится во втором предложении следующего абзаца).
function reassign(arg) {
arg = "OMG";
}
const unreassignable = "What";
let reassignable = "is";
let non_primitive = { val: "happening" };
reassign(unreassignable);
reassign(reassignable);
reassign(non_primitive);
console.log(unreassignable, reassignable, non_primitive.val, "😱");
Было ли в вашей отгадке что-нибудь OMG
? Не должно, так как консоль покажет What is happening 😱
. Независимо от того, что передаётся в функцию в JavaScript, переназначение изменяет только переменную аргумента. Так, ни const
, ни let
здесь ничего не меняют, потому что функция не получает саму переменную. Но что произойдёт, если мы попробуем изменить свойства аргумента?
Я создал ещё одну функцию, которая пытается изменить свойство val
своего аргумента. Попробуйте угадать, какое сообщение появится в консоли на этот раз.
function change_val_prop(arg) {
try {
arg.val = "OMG";
} catch (ignore) {}
}
const a_string = "What";
const a_number = 15;
const non_primitive = { val: "happening" };
const non_primitive_read_only = Object.freeze({ my_string: "here" });
change_val_prop(a_string);
change_val_prop(a_number);
change_val_prop(non_primitive);
change_val_prop(non_primitive_read_only);
console.log(
a_string.val,
a_number.val,
non_primitive.val,
non_primitive_read_only.val,
"😱"
);
Есть ли теперь OMG
в вашей догадке? Отлично, сообщение undefined undefined OMG undefined 😱
. Единственный раз, когда функция могла бы изменить свойство, это с общим объектом. О чем это нам говорит? Есть ли разница между тем, как передаются примитивные значения и как объекты? Неужели передача замороженного объекта внезапно меняет его на передачу по значению? Я думаю, что полезнее рассматривать их как равные.
Теперь о той ловкости рук, о которой я говорил. Практически все ресурсы делают такую вещь, когда говорят, что примитивы и объекты передаются по-разному, а затем сразу же приводят пример, где они обрабатываются по-разному. Посмотрите на описание функции в MDN. На момент написания этой статьи она описывалась так (выделение моё):
Аргументы могут передаваться по значению (в случае примитивных значений) или по ссылке (в случае объектов). Это означает, что если функция переназначит параметр примитивного типа, его значение не изменится за пределами функции. В случае параметра объектного типа, если его свойства будут изменены, это изменение отразится за пределами функции.
Я только что показал, что переназначение также не изменит объект. Вы не можете изменить свойства примитивов, потому что они доступны только для чтения, что также относится и к замороженным объектам. И большинство примеров, которые вы найдёте, делают то же самое. Сначала они указывают разницу между двумя значениями, а затем демонстрируют её, используя различные методы для каждого значения.
Я не пытаюсь критиковать, не поймите меня неправильно. Возможно, это было сделано потому, что это объясняет причуды JavaScript более привычным способом. Просто имейте в виду, что иногда объяснение даёт вам модель мышления о проблеме. Но эта модель никогда не бывает полностью верной природе проблемы.
Взгляд на эту проблему с точки зрения того, что примитивы — это такие же застывшие объекты, поможет вам понять, что происходит на самом деле. Альтернативные решения становятся нелогичными. А теперь, открыв это понятие объекта-примитива, который никто не может изменить, давайте сделаем их более дружественными для остальной части вашей программы.
Преобразование
Примитивные значения существуют сами по себе; любая программа знает, как с ними работать. Объекты могут быть чем угодно. И даже если вы назовёте их примитивными, этого недостаточно, чтобы они вдруг стали гражданами первого класса. Чтобы добиться этого, нам нужно проделать определённую работу.
Вы можете определить способ преобразования объектов в примитивные значения, такие как строки или числа. Например, давайте создадим объект, представляющий оценку от нуля до пяти. Нам нужно иметь возможность работать с числовым представлением для сравнения и сортировки. Нам также нужно иметь возможность выводить его в виде текста.
Существуют определённые методы, которые вы можете определить для описания представления вашего объекта. Помните [объект Object]
? Это то, что получается, когда вы пытаетесь превратить ваш объект в строку:
String({}); // "[object Object]"
Строковое представление
Этот вывод происходит из метода по умолчанию toString
, определённого в прототипе Object
. Но вы можете переписать его, определив его в своём собственном объекте.
String({ toString: () => "hello there" }); // "hello there"
Это то, что мы будем использовать для наших объектов рейтинга. Чтобы это было удобно, создадим функцию, которая инициализирует и замораживает такие объекты. Она также будет проверять, находится ли значение в диапазоне от нуля до пяти, и возвращать undefined
в противном случае.
function new_rating(value) {
const max = 5;
// Этот символ требует текстового представления (кому вообще нужны эмодзи 🙄).
const text_only = "\ufe0e";
const star = "⭑" + text_only;
const no_star = "⭐" + text_only;
if (
!Number.isSafeInteger(value) ||
(value < 0 || value > max)
) {
return undefined;
}
return Object.freeze({
value,
toString: () => star.repeat(value) + no_star.repeat(max - value))
});
}
Теперь давайте оценим что-нибудь. Есть ручка, которая мне нравится. Она довольно замечательная, и я бы дал ей пять звёзд.
const ratings = new WeakMap();
ratings.set(jetstream_pen, new_rating(5));
Эта WeakMap
для рейтингов — это способ присвоения свойств объектам без их фактического изменения. Теперь, когда мы хотим получить рейтинг, мы можем преобразовать оба наших объекта в строки.
if (ratings.has(jetstream_pen)) {
console.log(`${jetstream_pen} ${ratings.get(jetstream_pen)}`);
// "Uni-Ball Jetstream 0.5 ⭑︎⭑︎⭑︎⭑︎⭑︎"
}
Обернув оба объекта в литерал шаблона строки, я использовал метод toString
. В противном случае вы могли бы просто вызвать для них функцию String
, как я сделал в начале этого раздела.
Для нумерофилов
Для чисел существует метод valueOf
, который вызывается при каждой попытке преобразования в числовые сравнения или математические операторы (кроме +
). Давайте добавим его в нашу функцию new_rating
:
function new_rating(value) {
// ...
return Object.freeze({
value,
valueOf: () => value,
toString: () => star.repeat(value) + no_star.repeat(max - value)
});
}
Теперь может показаться излишним возвращать свойство value
напрямую. Но помните, что никто, кроме нас, не знает, что оно есть. Возврат его из valueOf
— это универсальный способ получить числовое представление.
Допустим, у нас снова есть объект pen
. И допустим, что рейтинг теперь является его свойством (просто для упрощения примера). Теперь мы можем отфильтровать товары с менее чем четырьмя звёздами:
articles.filter((item) => item.rating > 3);
// [ { name: "Uni-Ball Jetstream 0.5", ... } ]
Аналогичным образом мы можем сортировать элементы по рейтингу. Мы можем сделать это с помощью метода sort
массивов. Вероятно, у вас уже есть любимая функция сортировки, которую вы хотели бы использовать, например, эта:
function sorter(first, second) {
return second.rating - first.rating;
}
const sorted_by_rating = array_of.sort(sorter);
Теперь в sorted_by_rating
хранится массив самых лучших товаров.
Заключение
Я редко смотрел на объекты как на что-то, что может расширить возможности JavaScript. С примитивными объектами я пытаюсь исследовать именно это. Есть вещи, которые мы всё ещё не можем добавить, например, новые операторы или литеральный синтаксис, но всё же с помощью примитивных объектов мы можем определить новые типы значений.
В этой первой части серии Примитивные объекты я попытался дать общее представление о том, как сделать объекты похожими на некоторые свойства примитивов. Вы замораживаете их, чтобы сделать доступными только для чтения. Вы также можете определить представление в примитивах, число или строку, чтобы заставить их работать с арифметическими операторами или выводить их в текст.
В следующих частях, которые появятся на следующей неделе, я постараюсь привести больше примеров использования и сравнения с другими подходами, с которыми я сталкивался. Вы увидите, как упростить создание примитивных объектов и превратить их в структуры.
В этой серии я стараюсь затронуть функции JavaScript, на которые можно положиться. Даже если не всё из этого имеет смысл, я надеюсь, что, рассмотрев некоторые из приведённых здесь примеров, вы узнаете что-то полезное, что позволит сделать работу с JavaScript менее хрупкой без ненужного обращения к дополнительным инструментам.