В этой статье я попытаюсь объяснить, в чем дело со ссылкой на память в 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, когда мы присваиваем значение переменной, мы можем сделать это двумя разными способами:
- Прямое присвоение неизменяемого значения, которое происходит со всеми примитивными типами данных (число, строка, логическое значение, символ, bigint, undefined и null)
- Или сохранение ссылки на конкретное место в памяти, с объектами.
вступление
Следующий раздел частично взят здесь, так что не стесняйтесь обращаться к нему за дополнительной информацией.
Чтобы углубиться в теорию, движки 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 }
Разрешение упражнения
Наконец… теперь мы можем начать думать об упражнении, о котором я упоминал ранее, найдите минутку, чтобы прочитать его, если вы забыли предпосылку… 😅.
Итак, чтобы решить проблему, сначала мы должны подумать, как мы собираемся выполнить указанную реализацию. Во-первых, мы должны отслеживать количество открытых скобок в любой момент:
Во-вторых, мы хотим создавать новый массив каждый раз, когда мы сталкиваемся с ‘(‘,, чтобы рассматривать его как наш текущий депозит, чтобы заполнить текущими буквами.
Проблемы возникают с необходимостью создания дополнительных массивов, когда предыдущие все еще «открыты» (т. е. вложенные массивы), потому что нам нужно будет вернуться и продолжать заполнять все предыдущие массивы по мере закрытия их потомков. К счастью, у нас есть именно то, что нужно: стек, также известный как chunkHistory
array. А поскольку у нас никогда не будет (как утверждается в упражнении) незакрывающихся скобок, это будет работать идеально. Итак, всякий раз, когда нам нужно создать массив, мы помещаем его в нашу историю.
Но вот сложная часть: возвращаясь к предыдущему массиву, мы хотим, чтобы в нем также были изменения, которые мы внесли в тот, который мы только что покинули. Чтобы решить эту проблему, мы будем помещать каждый новый массив в два разных места: сам массив истории и предыдущий элемент истории. И мы должны сделать это с точно одной и той же ссылкой на память для них обоих (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)
. Конечно, это можно сделать через любой другой экземпляр, но тот, который мы используем, найти проще всего. После его закрытия (обнаружения ')' он сначала переворачивается, а затем выталкивается из истории, а следующие буквы вставляются в его предыдущий элемент.
Вот и все, надеюсь, это не стало слишком утомительным, мне было очень весело делать это, так как я улучшал код и узнавал больше по пути. Будьте здоровы и увидимся в следующий раз.