В этом уроке мы познакомимся еще с одной особенностью, которая касается изменяемых данных. Это так называемые ссылки. И знакомиться мы с ними будем через Lazy Evaluation.
Lazy Evaluation
Lazy Evaluation — это ленивые вычисления или отложенные вычисления, которые применяются в некоторых языках программирования. Это стратегия вычисления, согласно которой вычисления следует откладывать до тех пор, пока не понадобится их результат.
В реальности это касается не только языков. Это применяется и на уровне библиотек. Сейчас мы увидим, зачем это нужно, и как это работает.
Ленивость в том или ином виде существует во всех языках программирования. В основном это касается логических выражений. И в JavaScript она тоже есть.
Например, если мы встречаем такое логическое выражение, то его выполнение идет слева направо:
// true
true || console.log('message');
Если мы проверяем true
, и далее стоит символ или (||
), то нам неважно, что будет справа. Эта часть кода не повлияет на то, что результатом будет истина.
Это такая стратегия оптимизации внутри, которая позволяет не вычислять правое значение. Программисты пользуются этим, чтобы проверять существование какого-то объекта, например, что он не равен NULL
, и вызывать дальше какой-то метод. Это проверка на существование позволяет не писать сложные куски кода.
Если используется false
и символ «и» (&&
), то неважно, что будет справа. В любом случае будет вычислено false
:
// false
false && console.log('message');
Язык, в котором ленивые вычисления повсеместные, — это язык Haskell.
Ленивые коллекции
Ленивые вычисления помогают при работе с ленивыми коллекциями. Для практически любого языка написано большое количество подобных коллекций. Во многих языках они идут из коробки, например, в языке Ruby.
В Java есть дополнительные библиотеки. И в JavaScript их тоже достаточно много. Посмотрим на пример библиотеки из GitHub:
const numbers = Lazy.generate(Math.random)
.map((e) => Math.floor(e * 1000) + 1)
.uniq()
.take(300);
numbers.each((e) => console.log(e));
Эта библиотека показывает, как она работает. Здесь мы делаем Lazy.generate
и передаем туда функцию, которая может сгенерировать числа. В данном случае это Math.random
. После этого мы делаем map
, который преобразует их все, делает какие-то умножения, округления и сложения. Далее находим uniq
— уникальные числа. И после этого мы берем 300 элементов.
Тут возникает сразу несколько вопросов. Например, как могут применяться перечисленные функции, если мы не знаем, в каком количестве будут сгенерированы элементы посредством функции рандомизации. Так как речь идет о ленивых коллекциях, можно догадаться, что на самом деле никаких вычислений не происходит. Мы хоть и задаем их, но не пытаемся использовать.
Поэтому в реальности каждый такой вызов складывается внутрь — запоминается внутри этого объекта. Вычисления при этом не происходят.
Когда мы в самом конце вызываем функцию take
и передаем туда параметр 300,
становится понятно, что мы хотим использовать их. При этом нам нужно взять 300 элементов. В этот момент начинается отработка всех функций, которые были перечислены.
И если мы переберем их с помощью number.each((e) => console.log(e))
, то получим результат.
Необязательно делать take
. Часто конкретное использование — это использование уже циклических различных конструкций или функций высшего порядка, которые делают перебор. Например, это each
.
Польза ленивых коллекций
Ленивые коллекции позволяют работать с бесконечными списками. При этом они не пытаются генерировать и забивать всю память.
Если бы ленивых коллекций не было, то строчка Lazy.generate(Math.random)
просто бы повесила нам компьютер, так как бесконечно пыталась бы создать коллекцию. Но в нашем примере это не происходит.
Также ленивые коллекции позволяют строить гораздо более эффективную обработку. Например, в нашем примере мы описываем цепочку функций. Если у нас нет оптимизаций в языке, то обычно функции высшего порядка обрабатывают коллекции последовательно. То есть сначала функция делает один проход, потом другая функция делает еще один. В итоге получается много проходов.
Обычно коллекции достаточно небольшие. Поэтому в них не будет больших проблем при таком подходе. Также в некоторых языках есть оптимизации. И ленивые коллекции тоже позволяют это оптимизировать.
Так в каждом проходе применяются все эти функции для каждого элемента. И за счет этого вместо десятков проходов по коллекции мы можем получить только один.
LINQ
Разберем, как добавить ленивые функции в LINQ. Для этого надо сделать несколько изменений:
constructor(collection, operations) {
this.collection = collection;
this.operations = operations || [];
}
select (fn) {
const newOps = this.operations.slice();
newOps.push((coll) => coll.map(fn));
return new Enumerable(this.collection.slice(), newOps);
}
Теперь у нас внутри есть operations
— операции, которые мы собираемся делать. И здесь используется еще один подход.
В третьей строчке кода написано operations
или пустой массив. Это нужно в том случае, когда нам не передают operations
. В обычном случае его никто не передает, не подразумевается, что пользователь знает про эти параметры и пользуется ими. Мы просто получаем на вход undefined
и результатом проверки будет пустой массив в том случае, если operations
не передан.
Работа в функции выбора происходит следующим образом. Сначала мы вызываем slice
на operations
.
Это важно, потому что если мы начнем менять operations
сразу и добавлять туда новую операцию, то мы поменяем текущий объект. Это объект, из которого мы пытаемся получить новый. Новый должен обладать новым поведением и новым свойством выборки. Поэтому мы сначала генерируем новый operations
и после этого уже туда добавляем функцию обработки. В нашем случае это будет map
.
По сути у нас в operations
хранятся функции, которые принимают на вход коллекцию и делают внутри обработку. В данном случае мы добавляем map
.
В итоге этот код не выполняется, так как это отложенные вычисления — мы их просто запомнили. После этого мы передаем копию collection
в new Enumerable
первым параметром, а вторым параметром — newOps
.
Здесь становится понятно, как должен выглядеть наш toArray
, который должен применить к коллекции все операции. То есть нам нужно пропустить коллекцию сквозь все функции, которые там описаны, и получить результат. Но здесь есть еще одна ошибка, которая неочевидна.
Сравнение объектов
Попробуем сравнить объекты. Например, если сравнить два пустых массива, то можно выяснить, что они друг другу неравны. Если массив будет не пустой, произойдет то же самое:
[] === []; // false
['cat', 'dog'] === ['cat', 'dog']; // false
И то же самое касается пустых и непустых объектов:
const a = {};
const b = {};
а === b; // false
В этих примерах поведение одинаковое, потому что и то и то является объектом.
Проблема в том, что объекты хранятся по ссылке. То есть когда мы делаем такое присваивание, то внутри a
оказывается не сам объект или значение, а ссылка на него. Поэтому когда мы их сравниваем — это создание нового объекта. И ссылка ведет на одну область памяти.
А в b
происходит создание другого объекта, несмотря на то, что он структурно тот же. Но это не тот же объект. То есть здесь понятие изменяемости вводит понятие разности.
Например, счет с деньгами может быть один. При этом на нем сначала то одно количество денег, то другое. Но счет все равно один. Здесь та же ситуация.
Причем примитивные типы являются неизменяемыми, они присваиваются и работают не по ссылке, а по значению. Их всегда можно сравнить. А все типы объектов сравниваются по ссылкам, и поэтому они так работают.
Если мы работаем в тестах, то equal
тоже нам скажет, что они false
— неравны, а deepEqual
скажет, что они true
— равны:
assert.equal(a, b); // false
assert.deepEqual(a, b); // true
Есть хороший пример, по которому понятно, как это работает, и как об этом думает JavaScript.
Например, для потребителя неважно, какая у нас купюра, важен только ее номинал. Допустим, у нас есть 100 долларов. Если мы поменяем купюру, у нас все равно окажется 100 долларов. Для нас важно само значение. А для производителя купюр, каждая купюра имеет свой номер, и ведется соответствующий учет.
Передача по ссылке
Разберемся, в чем у нас возникает коллизии, когда мы получаем поведение, которое нас чаще всего не устраивает. Пример:
const numbers = [10, 8, 1, 7, 4];
const mySort = (coll) => coll.sort();
mySort(numbers); // [ 1, 10, 4, 7, 8 ]
// side-effect
console.log(numbers); // [ 1, 10, 4, 7, 8]
Здесь в функцию, которая принимает коллекцию и делает ее сортировку, передаем numbers
. В этом случае происходит так называемый pass-by-reference — передача по ссылке.
Если мы распечатаем numbers
, мы увидим, что они отсортированы. Дело в том, что мы передаем туда на самом деле ссылку на изначальный массив. А sort
сортирует in place — перемешивает всю коллекцию и в итоге numbers
меняются. Это и есть сайд-эффект, когда наша функция нечистая.
Это иногда нужно для эффективности, но желательно не писать такой код, хотя с объектами так всегда и получается.
Общий пример
Рассмотрим общий пример, чтобы понять, как это работает. Допустим, у нас есть функция, которая принимает три параметра:
const f = (a, b, c) => {
a = a * 10;
b.item = 'changed';
с = { item: 'changed' };
};
const num = 10;
const obj1 = { item: 'unchanged' };
const obj2 = { item: 'unchanged' };
f(num, obj1, obj2);
console.log(num); // 10
console.log(obj1.item); // changed
console.log(obj2.item); // unchanged
a
— это скаляр, число. В этом случае мы говорим, чтоa = a * 10
.b
— это объект, мы у него меняем одно полеc
— это тоже объект, но мы его целиком заменяем на другой объект
Далее вызываем функцию, которую определили выше, передаем туда эти параметры и распечатываем. Посмотрим, что же из них изменилось:
console.log(num); // 10
console.log(obj1.item); // changed
console.log(obj2.item); // unchanged
Мы видим, что num
не изменился. Как он был 10
, так и остался. Он передается по значению, и когда мы говорим a = a * 10
, создается локальное окружение, внутри которого записывается свой a
. Он с внешним a
никак не связан.
Далее мы вводим obj1.item
, и он действительно изменился. Если объекты передаются по ссылке и мы меняем у него какое-то поле внутри, то меняется внешний объект, потому что это он и есть.
Третий вариант: мы распечатываем obj2.item
, и он равен unchanged.
Мы производили полную замену, при этом имя было тоже c
. Но внутри теперь хранится ссылка на другой объект.
Это означает, что если у нас были другие ссылки на исходный объект, то они остаются. При этом сам объект остается неизмененный.
Выводы
В этом уроке мы познакомились еще с одной особенностью, которая касается изменяемых данных. Это так называемые ссылки. Мы узнали, что такое Lazy Evaluation. Это стратегия вычисления, согласно которой вычисления следует откладывать до тех пор, пока не понадобится их результат.
Также мы узнали, что ленивые вычисления помогают при работе с ленивыми коллекциями. Для практически любого языка написано большое количество подобных коллекций. Они позволяют работать с бесконечными списками и строить гораздо более эффективную обработку.
Еще мы разобрали, как добавлять ленивые функции в LINQ и как сравнивать пустые и непустые объекты.
Дополнительные материалы

Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
- Статья «Как учиться и справляться с негативными мыслями»
- Статья «Ловушки обучения»
- Статья «Сложные простые задачи по программированию»
- Урок «Как эффективно учиться на Хекслете»
- Вебинар «Как самостоятельно учиться»
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.