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

Одно значение можно сохранить как есть, но когда их количество неизвестно, то нужен другой подход.

Время чтения: 10 мин

Кратко

Скопировано

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

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

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

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

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

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

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

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

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

Скопировано

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

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

        
        
          
        
      

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

        
          
          const sevenAgain = seven // 0b0111
          const sevenAgain = seven // 0b0111

        
        
          
        
      

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

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

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

        
          
          console.log(seven === sevenAgain)// true
          console.log(seven === sevenAgain)
// true

        
        
          
        
      
Побайтовое сравнение величин с результатом true
        
          
          console.log(seven === eight)// false
          console.log(seven === eight)
// false

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

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

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

Скопировано

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

        
          
          const myData = {}
          const myData = {}

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

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

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

        
          
          const yourData = myData
          const yourData = myData

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

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

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

        
          
          const data = {}const anotherData = dataconsole.log(data === anotherData)// true
          const data = {}
const anotherData = data

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

        
        
          
        
      

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

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

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

        
        
          
        
      

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

        
          
          yourData.name = 'Саша'console.log(myData)// { name: 'Саша' }myData.name = 'Михаил'console.log(yourData)// { name: 'Михаил' }
          yourData.name = 'Саша'
console.log(myData)
// { name: 'Саша' }

myData.name = 'Михаил'
console.log(yourData)
// { name: 'Михаил' }

        
        
          
        
      

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

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

        
          
          let user = { name: 'Анна', age: 21 }const admin = user// Переопределение никак не повлияет на admin, потому что мы создали новый объектuser = { name: 'Иван' }console.log(admin) // { name: 'Анна', age: 21 }admin.isAdmin = trueconsole.log(user) // { name: 'Иван' }console.log(admin) // { name: 'Анна', age: 21, isAdmin: true }
          let user = { name: 'Анна', age: 21 }
const admin = user

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

console.log(admin) // { name: 'Анна', age: 21 }

admin.isAdmin = true

console.log(user) // { name: 'Иван' }
console.log(admin) // { name: 'Анна', age: 21, isAdmin: true }

        
        
          
        
      

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

Скопировано

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

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

        
          
          const admin = {  name: 'Анна',  age: 21,  isAdmin: true,}// Чтобы скопировать через Object.assign() нужно передать пустой объектconst adminCopy = Object.assign({}, admin)const anotherCopy = {  ...admin,}
          const admin = {
  name: 'Анна',
  age: 21,
  isAdmin: true,
}

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

const anotherCopy = {
  ...admin,
}

        
        
          
        
      

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

        
          
          anotherCopy.age = 30anotherCopy.isAdmin = falseconsole.log(anotherCopy)// {name: 'Анна', age: 30, isAdmin: false }console.log(admin)// {name: 'Анна', age: 25, isAdmin: true }
          anotherCopy.age = 30
anotherCopy.isAdmin = false

console.log(anotherCopy)
// {name: 'Анна', age: 30, isAdmin: false }

console.log(admin)
// {name: 'Анна', age: 25, isAdmin: true }

        
        
          
        
      

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

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

const copy = { ...original }
copy.b.c = 2

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

        
        
          
        
      

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

        
          
          const cat = {  name: 'Феликс',  color: 'чёрный',  isHomeless: false,}const catInBoots = {  ...cat,  name: 'Пушок',  hasBoots: true,}console.log(catInBoots)// {name: 'Пушок', color: 'чёрный', isHomeless: false, hasBoots: true }const redCat = Object.assign(cat, { color: 'рыжий', name: 'Борис' })console.log(redCat)// {name: 'Борис', color: 'рыжий', isHomeless: false }
          const cat = {
  name: 'Феликс',
  color: 'чёрный',
  isHomeless: false,
}

const catInBoots = {
  ...cat,
  name: 'Пушок',
  hasBoots: true,
}

console.log(catInBoots)
// {name: 'Пушок', color: 'чёрный', isHomeless: false, hasBoots: true }

const redCat = Object.assign(cat, { color: 'рыжий', name: 'Борис' })

console.log(redCat)
// {name: 'Борис', color: 'рыжий', isHomeless: false }

        
        
          
        
      

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

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

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

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

Скопировано

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

  • При передаче примитивного типа данных, его значение копируется в аргумент.
  • При использовании ссылочного типа данных копируется ссылка. Все изменения в объекте, который был передан в качестве аргумента, будут видны всем, кто владеет ссылкой:
        
          
          const member = { id: '123', name: 'Иван' }function makeAdmin(user) {  user.isAdmin = true  return user}const admin = makeAdmin(member)console.log(admin)// { id: '123', name: 'Иван', isAdmin: true }console.log(member)// { id: '123', name: 'Иван', isAdmin: true }// Это один и тот же объектconsole.log(admin === member)// true
          const member = { id: '123', name: 'Иван' }

function makeAdmin(user) {
  user.isAdmin = true

  return user
}

const admin = makeAdmin(member)

console.log(admin)
// { id: '123', name: 'Иван', isAdmin: true }
console.log(member)
// { id: '123', name: 'Иван', isAdmin: true }

// Это один и тот же объект
console.log(admin === member)
// true

        
        
          
        
      

Заключение

Скопировано

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

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

На практике

Скопировано

Егор Огарков советует

Скопировано

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

        
          
          const dog = {  name: 'Барбос',  color: 'чёрный',}const puppy = {  ...dog,  // Можно выставить значение undefined, но это не удаление  color: undefined,}// А это удалит поле, хоть delete считается мутированием// Но использование его на копии изменит только puppy, dog не будет измененdelete puppy.color
          const dog = {
  name: 'Барбос',
  color: 'чёрный',
}

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

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

        
        
          
        
      

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

На собеседовании

Скопировано
Задать вопрос в рубрику
🤚 Я знаю ответ

Саша Патлух  отвечает

Скопировано

Что проверяют:

Это часто встречающаяся группа вопросов на собеседовании. Отличить её можно по таким признакам: вам предлагают два и более JS-объекта, в которых программно меняют свойства по ключам. Спрашивают, что в результате попадёт в консоль при вызове какого-то определённого ключа.

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

Ответ:

Обычно перед вами будет пример, вроде такого:

        
          
          let cat = {  name: 'Tom',  isHunter: true}let mouse = {  name: 'Jerry',  isHunter: false}mouse = cat;cat.isHunter = false;mouse.isHunter = undefined;console.log(mouse.isHunter);
          let cat = {
  name: 'Tom',
  isHunter: true
}

let mouse = {
  name: 'Jerry',
  isHunter: false
}

mouse = cat;
cat.isHunter = false;
mouse.isHunter = undefined;

console.log(mouse.isHunter);

        
        
          
        
      

Разберёмся, как выполняется этот код.

  1. В начале переменные mouse и cat указывают на собственные объекты.
  2. В строке mouse = cat мы присваиваем переменной mouse ссылку на объект cat. Можно рассматривать это как "связывание" переменных. Теперь обе переменные указывают на один и тот же объект { name: 'Tom', isHunter: true}.
  3. Теперь mouse.name и cat.name будут менять значения свойства одного и того же объекта.
  4. Последнее изменение в этом объекте происходит присваиванием значения undefined ключу isHunter.
  5. Выводя в консоль значение ключа isHunter, получим последнее его обновление — undefined.

Такой способ рассуждения классно описан в книге "Just JavaScript" Дэна Абрамова. Он предлагает метафору, где имена переменных связываются проводами с данными в них, а ключи в объектах представляются как провода с подписями. Когда вы меняете значения в объектах или переназначаете связи объектов и переменных, вы просто переключаете провода из одного штекера в другой.

let cat = { name: 'Tom', isHunter: true}

Схема. Объект cat имеет свойство name со значением Tom и свойство isHunter со значением true

mouse = cat

Схема. В объект mouse присваивается объект cat со всеми свойствами и значениями

Такие же рассуждения подходят и для более сложных случаев:

        
          
          let cat = {  name: 'Tom',  isHunter: true}let mouse = {  name: 'Jerry',  isHunter: false}cat.isHunter = mouse.isHunter;mouse.isHunter = undefined;mouse = cat;console.log(mouse.isHunter);
          let cat = {
  name: 'Tom',
  isHunter: true
}

let mouse = {
  name: 'Jerry',
  isHunter: false
}

cat.isHunter = mouse.isHunter;
mouse.isHunter = undefined;
mouse = cat;

console.log(mouse.isHunter);

        
        
          
        
      

В этом примере в консоли будет false, ведь данные в объекте, который выводится в консоль, изменены только однажды в строке cat.isHunter = mouse.isHunter. После переменная mouse была «подключена» к тому же объекту, на который указывает переменная cat.