skip to main content

Хранение по ссылке vs. по значению

js

Кратко 🔗

Для хранения различных значений в переменных мы используем разные типы данных. Однако хранятся эти значения по-разному. Примитивные значения (например, числа или строки) хранятся в переменной как есть, а объекты, массивы и функции — по ссылке на место в памяти.

Представим ситуацию, когда у вас в руках есть ложка из набора, и вы кладёте его в какой-то ящик. Такой простой метафорой можно описать присвоение значения в переменную, если представить ящик как переменную, а ложку как значение. Таким образом можно определить и хранение по значению. В следующий раз, когда вы захотите взять ложку, вы можете открыть тот же самый ящик и получить это значение-ложку.

Теперь представим, что у нас есть другая ложка — это специальная ложка, удобная, но при этом никто не знает какого она размера и как её правильно хранить. Но для удобства вам хотелось так же использовать ящик. Потому когда вы открыли этот же ящик, то там уже не лежит эта ложка, зато находится записка о том, где эту ложку можно найти.

В итоге чтобы получить ложку вам нужно обратится по этому «адресу», предположим что теперь все такие ложки лежат в специальной «ложечной», которые могут их вместить, и только оттуда их можно достать. А потому, чтобы получить ложку вам нужно обратиться по данному адресу. Аналогичным образом мы можем положить записки с тем же адресом и в другие ящики, чтобы каждый, кто обращался к ящику знал где найти ложку. Теперь ваша ложка хранится по ссылке.

В чем же фундаментальное отличие?

Отличий несколько, некоторые могут приводить к неприятным последствиями в нашем коде.

То, как будут хранится данные, жёстко связано с типом данных. Нельзя заставить значение примитивного типа хранится по ссылке, и наоборот.

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

Примитивные типы данных 🔗

Когда мы объявляем переменную и кладем в нее примитивное значение, то в память записывается какое-то количество байт, которое описывает это значение. Таким образом можно сказать, что наша переменная уже сразу содержит эти байты.

const seven = 7 // 0b0111
const eight = 8 // 0b1000

Если присвоить какое-то значение переменной в другую, то мы просто скопируем это же количество байт в новое место.

const sevenAgain = seven // 0b0111

В итоге все наши переменные можно схематически отобразить таким образом:

Схематическое отображение переменных

Когда мы сравниваем два значения, то у нас по сути произойдет побайтовое сравнение этих величин.

seven === sevenAgain // true

Побайтовое сравнение величин с результатом true

seven === eight // false

Побайтовое сравнение величин с результатом false

Из-за того, что все примитивные значения хранятся в небольшом и фиксированном количестве байт операции над ними выполнять несложно. Такие типы данных называют примитивными. В них входят числа (number), строки (string), булевы (boolean), а так же специальные значения null и undefined.

Ссылочные типы данных 🔗

С объектами и другими сложными данными дела обстоят сложнее из-за того, что мы не знаем, какое количество памяти для них понадобится. Во время работы с такой структурой компьютеру необходимо следить за тем, сколько памяти уже есть, сколько понадобится, и выделять новую. Работать с такими данными сложнее. Для этого компьютер отдает нам ссылку на место, где данные хранятся, и самостоятельно будет работать с ними по инструкциями, которые мы ему даем. Таким образом в переменную мы получаем лишь ссылку на данные.

const myData = {}

Схематичное изображение переменной myData со ссылкой на участок памяти

Обратите внимание, что направление стрелки поменялось. Так мы обозначим, что наша переменная ссылается на участок памяти.

☝️ Если сейчас присвоить значение из myData в другую переменную, то мы скопируем ссылку, а не само значение.

const yourData = myData

Схематичное изображение переменных myData и yourData со ссылкой на общий участок памяти

Такой тип данных называется ссылочным и в него входят объекты, массивы и функции. На самом деле и массивы и функции все они так же являются объектами, но это другая история.

Можно ли в таком случае рассчитывать, что значения будут равными? Конечно, можно! В этом случае сравниваться будут ссылки на объект, а не их содержимое. Потому, если обе переменных указываются на одно и то же, смело можно сказать, что значения равны.

const data = {}
const anotherData = data

console.log(data === anotherData) // true

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

const cat = { name: "Felix" }
const dog = { name: "Felix" }

// Странно ожидать равность кошки и собаки ¯\_(ツ)_/¯ но теперь мы знаем причину
console.log(cat === dog) // false

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

yourData.name = "Alex"

console.log(myData) // { name: 'Alex' }

myData.name = "Michel"

console.log(yourData) // { name: 'Michel' }

Эта особенность часто становится причиной ошибок при работе с ссылочными типами данных, т.к можно легко забыть или даже не знать, что же еще ссылается на тот же объект.

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

const user = { name: "Anna", age: 21 }
const admin = user

// Переопределение никак не повлияет на admin, потому что мы создали новый объект
user = { name: "John" }

console.log(admin) // { name: 'Anna', age: 21 }

admin.isAdmin = true

console.log(user) // { name: 'John' }
console.log(admin) // { name: 'Anna', age: 21, isAdmin: true }

Мутации и неизменяемость 🔗

Изменение значений у полей объекта, добавление или удаление их отразится на всех, кто владеет ссылкой на этот объект. Такие операции называют мутациями. В современных веб-разработке мутаций стараются избегать, потому что мутирование объектов может приводить к ошибкам, которые очень трудно отследить. Однако если мы твердо уверены, что объект нигде более не используется или четко контролируем ситуацию, то изменение объекта напрямую гораздо проще.

Если нужно безопасно модифицировать объект, то для начала придётся его скопировать. Скопировать объект можно двумя способами: через Object.assign или используя оператор троеточия ...

const admin = {
name: "Anna",
age: 21,
isAdmin: true,
}

// Чтобы скопировать через Object.assign нужно передать пустой объект
const adminCopy = Object.assign({}, admin)

const anotherCopy = {
...admin,
}

Таким образом будет создана совсем новая сущность, которая будет содержать ровно те же значения. Любые изменения в новом объекте уже не затронут предыдущий.

anotherCopy.age = 30
anotherCopy.isAdmin = false

console.log(anotherCopy) // {name: 'Anna', age: 30, isAdmin: false }
console.log(admin) // {name: 'Anna', age: 25, isAdmin: true }

Здесь стоит внести важную оговорку о вложенных объектах. При копировании объекта указанным выше способом безопасно скопируется только поля верхней вложенности (так называемый shallow copy). Т.е. любые вложенные объекты скопируются по ссылке, таким образом изменение их напрямую затронет и первоисточник.

const original = {
b: {
c: 1,
},
}

const copy = { ...original } // или Object.assign({}, original)

copy.b.c = 2

// Тоже изменился!
console.log(original) // { b: { c: 2 }}

Изменения можно так же внести при копировании.

const cat = {
name: "Felix",
color: "black",
isHomeless: false,
}

const catInBoots = {
...cat,
name: "Johny",
hasBoots: true,
}

// {name: 'Johny', color: 'black', isHomeless: false, hasBoots: true }
console.log(catInBoots)

const redCat = Object.assign(cat, { color: "red", name: "Boris" })

// {name: 'Boris', color: 'red', isHomeless: false }
console.log(redCat)

Если каждый раз создавать объект, когда мы вносим изменения, то такие объекты называют иммутабельными (immutable) или неизменяемыми. Результатом любой модификации такого объекта всегда должен быть новый объект, при этом старый никак не изменится.

С массивами, кстати, ситуация точно такая же — если изменять содержимое, то изменения отразятся на всех владельцев ссылки. Для копирования массивов, кроме оператора троеточия, можно использовать метод Array.slice. Методы Array.map и Array.filter — они тоже создают новый массив. Причем некоторые другие методы (например Array.sort, Array.splice) при использовании мутируют исходный массив, потому использовать их стоит с осторожностью. Подробнее о том, какой метод мутирует массив можно найти на Does It Mutate.

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

Аргументы функций 🔗

Про тип данных стоит помнить особенно внимательно при использовании функций. Когда мы используем значение как аргумент функции, то все особенности его типа данных сохраняются:

  • При передаче примитивного типа данных, его значение копируется в аргумент.
  • При использовании ссылочного типа данных копируется ссылка. Все изменения в объекте, который был передан в качестве аргумента, буду видны всем, кто владеет ссылкой:
const member = { id: "123", name: "John" }

function makeAdmin(user) {
user.isAdmin = true

return user
}

const admin = makeAdmin(member)

console.log(admin) // { id: '123', name: 'John', isAdmin: true }
console.log(member) // { id: '123', name: 'John', isAdmin: true }
// Это один и тот же объект
console.log(admin === member) // true

Заключение 🔗

Итак что мы узнали?

  • Примитивные типы данных (числа, булевы и строки) хранятся и сравниваются по значению. Можно безопасно менять значение переменной и не боятся, что изменится что-то еще
  • Ссылочные типы данных (объекты, массивы) хранятся и сравниваются по ссылке. При этом при сравнении будет учитываться именно факт того, что две переменные ссылаются на один и тот же объект. Даже если два объекта содержат идентичные значения это ни на что не повлияет
  • Изменения внутри объекта будут видны всем у кого есть ссылка на этот объект. Прямое изменение данных объекта называется мутирование. Лучше стараться избегать мутации объекта, т.к это может приводить к неочевидным ошибкам
  • Чтобы безопасно менять ссылочный тип данных его необходимо предварительно скопировать. Таким образом будет создана другая ссылка и любые изменения на затронут старый объект

В работе 🔗

🛠 При копировании можно изменить и добавить поля, но вот удалить без мутации нельзя

const dog = {
name: "Barbos",
color: "black",
}

const puppy = {
...dog,
// Можно выставить значение undefined, но это не удаление
color: undefined,
}

// А это удалит поле, хоть delete считается мутирование
// Но использование его на копии изменит только puppy, dog не будет изменен
delete puppy.color

🛠 Популярные в веб-разработке библиотеки React и Redux сильно завязаны на иммутабельности данных и практически построены на этом.

Автор: Егор,