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

Я взял это упражнение из CodeSignal, и его решение, хотя и не самое прямолинейное и простое, реализовано специально для объяснения этой темы.

Итак, проблема выглядит следующим образом:

Нам нужно написать функцию, которая меняет местами символы между скобками (возможно, вложенными) в нашей входной строке. Некоторые примеры могут быть:

  • «(foobar)» → приводит к «raboof»
  • ‘foo(bar(baz)se)bl(im)’ → приводит к ‘fooesbazrabblmi’
  • 'foo(bar)baz(blim)' → приводит к foorabbazmilb

Теперь мы будем использовать следующий подход:

Мы хотим создать массив с каждой буквой отдельно, а с каждой скобкой вложить дополнительный массив, чтобы предыдущие примеры выглядели так:

Таким образом, мы могли бы перевернуть каждый массив по мере его «завершения» или «закрытия» для получения желаемого результата. Итак, порядок во втором примере будет таким:

  • [‘b’, ‘a’, ‘z’]
  • [‘b’, ‘a’, ‘r’, [‘b’, ‘a’, ‘z’], ‘e’, ‘s’]
  • [‘i’, ‘m’]

Надеюсь, вы начинаете понимать мое намерение.

Чтобы сделать это, давайте сначала рассмотрим основы обращения к памяти.

Справочник по памяти

В Javascript, когда мы присваиваем значение переменной, мы можем сделать это двумя разными способами:

  1. Прямое присвоение неизменяемого значения, которое происходит со всеми примитивными типами данных (число, строка, логическое значение, символ, bigint, undefined и null)
  2. Или сохранение ссылки на конкретное место в памяти, с объектами.

вступление

Следующий раздел частично взят здесь, так что не стесняйтесь обращаться к нему за дополнительной информацией.

Чтобы углубиться в теорию, движки JavaScript хранят данные в двух местах: куча памяти и стек. Стек используется для хранения примитивов (или статических данных), размеры которых известны на во время компиляции, и поскольку их значения не изменятся(мы вскоре поговорим об этом), для каждого типа данных будет выделен фиксированный объем памяти, этот процесс называется выделение статической памяти.

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

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

примитивы

Когда мы назначаем что-либо в Javascript, мы говорим, что привязываем переменную к определенному значению (примитивному или не примитивному). Таким образом, эти «привязки» представляют собой указатель на набор битов, сохраненных в памяти. Давайте рассмотрим let variable = 5 Теперь мы могли бы переназначить (поскольку мы используем let) его значение, но мы не «преобразовываем» это 5, скажем, в 6, а сохраняем другой набор битов, который в сумме дает 6. И если мы создаем еще одну переменную const variable2 = 6, которая «имеет» такое же значение, мы заставим ее указывать на тот же набор битов, что и предыдущая, и вот почему в этот момент: variable === variable2 даст true. И это то, что === делает, он сравнивает переменные или значения на основе их конкретного места в памяти.

Все примитивы неизменяемы. Давайте рассмотрим эту новую переменную let str = 'cat'. Если мы попытаемся изменить его значение, Javascript просто проигнорирует его:

let str = 'cat'
str[0] = 'b'
console.log(str) // yields 'cat', not 'bat'

Каждый раз, когда мы переназначаем что-либо, думайте об этом не как об изменении предыдущего значения (или битов), а как создании нового.

Объекты

Объекты хранятся по-разному. Как было сказано ранее, их привязки указываютна группу битов, сохраненных в памяти, но на этот раз они представляют не значения, а адрес, по которому их можно найти(какой-то Косвенная адресация).

Здесь есть разница между наличием двух ссылок на один и тот же объект и наличием двух разных объектов, содержащих одни и те же свойства. Каждый раз, когда мы создаем новый объект, Javascript выделяет для его использования новый фрагмент памяти; вот почему, если мы сделаем что-то вроде:

const obj1 = { prop: 123 }
const obj2 = { prop: 123 }
console.log(obj1 === obj2) // false

Мы не создаем одинаковые объекты, потому что их расположение в памяти различно. Это не относится к примитивам, потому что, как мы сказали ранее, их размер известен до выполнения, поэтому Javascript может идеально выделить правильный объем памяти и использовать этот точный кусок битов для других переменных (имеющих то же значение).

Оба ведут себя одинаково при присвоении переменной другой переменной:

const obj1 = { prop: 123 }
const obj2 = obj1
console.log(obj1 === obj2) // true
const primitive1 = 123
const primitive2 = primitive1
console.log(primitive1 === primitive2) // true

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

Одно из основных отличий состоит в том, что объекты на самом деле изменяемые. В случае obj1 и obj2 мы каким-то образом связываем их вместе. Если кто-то из них изменит что-то внутри этого места/объекта { prop: 123 }, потому что они оба указывают на него, другой увидит те же изменения. По сути, с помощью obj1 мы создаем и сохраняем указатель на указанный объект, а с помощью obj2 мы просто заимствуем его, что можно рассматривать как оба имеют один и тот же указатель, а не значение. И они всегда будут указывать на одну и ту же ссылку на память, если только они не будут переназначены. Так:

const obj1 = { prop: 123 }
const obj2 = obj1
obj2.prop = 456
console.log(obj1) // { prop: 456 }
obj1.prop = 789
console.log(obj2) // { prop: 789 }

В случае с объектом фигурные скобки — это просто оболочка, которая группирует множество свойств вместе, она не закрывает и не предотвращает какие-либо модификации от внешнего агента. Подумайте об этом на этом примере:

const obj1 = { prop1: 123 }
const obj2 = { prop2: obj1 }
obj2.prop2.prop1 = 456
console.log(obj1) // { prop1: 456 }
obj2.prop2.anotherProp = 789
console.log(obj1) // { prop1: 456, anotherProp: 789 }
delete obj2.prop2.anotherProp
console.log(obj1) // { prop1: 456 }

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

И если мы сделаем то же самое, но на этот раз с примитивом, это не даст того же результата:

const obj1 = { prop1: 123 };
const obj2 = { prop2: obj1.prop1 };
obj2.prop2 = 456;
console.log(obj1); // { prop1: 123 }

Это потому, что вместо мутации мы переназначаем; отпустить и схватить другую ценность. Таким образом, если бы мы хотели связать два примитивных значения, нам нужно было бы обернуть их объектами. Предыдущий пример аналогичен следующему:

const obj1 = { prop1: 123 };
let obj2 = obj1
obj2 = { prop2: 456 };
console.log(obj1); // { prop1: 123 }

Разрешение упражнения

Наконец… теперь мы можем начать думать об упражнении, о котором я упоминал ранее, найдите минутку, чтобы прочитать его, если вы забыли предпосылку… 😅.

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

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

Проблемы возникают с необходимостью создания дополнительных массивов, когда предыдущие все еще «открыты» (т. е. вложенные массивы), потому что нам нужно будет вернуться и продолжать заполнять все предыдущие массивы по мере закрытия их потомков. К счастью, у нас есть именно то, что нужно: стек, также известный как chunkHistoryarray. А поскольку у нас никогда не будет (как утверждается в упражнении) незакрывающихся скобок, это будет работать идеально. Итак, всякий раз, когда нам нужно создать массив, мы помещаем его в нашу историю.

Но вот сложная часть: возвращаясь к предыдущему массиву, мы хотим, чтобы в нем также были изменения, которые мы внесли в тот, который мы только что покинули. Чтобы решить эту проблему, мы будем помещать каждый новый массив в два разных места: сам массив истории и предыдущий элемент истории. И мы должны сделать это с точно одной и той же ссылкой на память для них обоих (newChunk)

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

Думайте о цветах как об одной и той же ссылке на память.

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

Учитывая все это, мы не можем поступить следующим образом, даже если они кажутся одинаковыми:

chunkHistory.at(-1).push([])
chunkHistory.push([])

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

Но чего-то здесь не хватает… добавления букв в соответствующий массив. Итак, мы хотим вставлять только буквы (а не скобки):

А поскольку chunkHistory.at(-1)всегда является нашим наиболее вложенным, неполным массивом, мы знаем, что будем помещать буквы в правильный слой.

Теперь нам нужно разобраться с закрывающими массивами. Для этого мы должны принять во внимание пару вещей:

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

const reversedChunk = chunkHistory.at(-1).flat(chunkHistory.length).reverse();
chunkHistory.at(-1).length = 0;
chunkHistory.at(-1).push(...reversedChunk);
chunkHistory.pop();

Здесь мы сглаживаем все слои chunkHistory.at(-1), потому что он может содержать другой (уже инвертированный) массив внутри себя. Во-вторых, чтобы внести нечистые изменения в наш последний фрагмент, нам сначала нужно его очистить, а затем заполнить обратными значениями. Наконец, мы хотим, чтобы предыдущий фрагмент был последним элементом в нашей истории (текущим).

Поскольку мы не можем внести все эти изменения на месте, мы сначала создаем массив с желаемым состоянием (reversedChunk), а затем модифицируем наш текущий, чтобы он содержал те же значения. Наша финальная функция выглядит так:

Чтобы лучше понять, как это работает, вот значения chunkHistory для каждого шага нашего второго примера:

  • ‘foo(bar(baz)se)bl(im)’ → приводит к ‘fooesbazrabblmi’

Обратите внимание, как каждая новая буква вставляется во все элементы chunkHistory одновременно из-за одинаковых ссылок. Так, например, когда массив «баз» появляется в четвертой строке (изначально он создается пустым), чтобы заполнить его и все остальные экземпляры, все это делается с помощью всего лишь chunkHistory.at(-1).push(symbol). Конечно, это можно сделать через любой другой экземпляр, но тот, который мы используем, найти проще всего. После его закрытия (обнаружения ')' он сначала переворачивается, а затем выталкивается из истории, а следующие буквы вставляются в его предыдущий элемент.

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