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

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

Кратко

Секция статьи "Кратко"

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

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

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

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

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

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

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

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

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

Секция статьи "Примитивные типы данных"

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

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

        
        
          
        
      

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

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

        
        
          
        
      

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

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

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

        
          
          seven === sevenAgain // true
          seven === sevenAgain // true

        
        
          
        
      
Побайтовое сравнение величин с результатом true
        
          
          seven === eight // false
          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: "Felix" }const dog = { name: "Felix" }// Странно ожидать равность кошки и собаки ¯\_(ツ)_/¯ но теперь мы знаем причинуconsole.log(cat === dog) // false
          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' }
          yourData.name = "Alex"

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

myData.name = "Michel"

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

        
        
          
        
      

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

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

        
          
          let user = { name: "Anna", age: 21 }const admin = user// Переопределение никак не повлияет на admin, потому что мы создали новый объектuser = { name: "John" }console.log(admin) // { name: 'Anna', age: 21 }admin.isAdmin = trueconsole.log(user) // { name: 'John' }console.log(admin) // { name: 'Anna', age: 21, isAdmin: true }
          let 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,}
          const admin = {
  name: "Anna",
  age: 21,
  isAdmin: true,
}

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

const anotherCopy = {
  ...admin,
}

        
        
          
        
      

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

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

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

console.log(admin)
// {name: 'Anna', 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: "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)
          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 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

        
        
          
        
      

Заключение

Секция статьи "Заключение"

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

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

На практике

Секция статьи "На практике"

windrushfarer

Секция статьи "windrushfarer"

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

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

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

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

        
        
          
        
      

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