https://habrahabr.ru/company/ruvds/blog/334222/- Разработка веб-сайтов
- JavaScript
- Блог компании RUVDS.com
Ключевое слово
this
в JavaScript можно назвать одной из наиболее обсуждаемых и неоднозначных особенностей языка. Всё дело в том, что то, на что оно указывает, выглядит по-разному в зависимости от того, где обращаются к
this
. Дело усугубляется тем, что на
this
влияет и то, включён или нет строгий режим.
Некоторые программисты, не желая мириться со странностями
this
, стараются вовсе не пользоваться этой конструкцией языка. Не вижу тут ничего плохого. На почве неприятия
this
было создано много полезного. Например — стрелочные функции и привязка
this
. Как результат, при разработке можно практически полностью обойтись без
this
.
Означает ли это, что «борьба с
this
» окончена? Нет конечно. Если поразмыслить, можно обнаружить, что задачи, которые традиционно решают с привлечением функций-конструкторов и
this
, можно решить и другим способом. Этот материал посвящён описанию именно такого подхода к программированию: никаких
new
и никаких
this
на пути к более простому, понятному и предсказуемому коду.
Сразу хотелось бы отметить, что этот материал предназначен для тех, кто знает, что такое
this
. Мы не будем рассматривать здесь основы JS, наша главная цель — описание особого подхода к программированию, исключающего использование
this
, и, благодаря этому, способствующего устранению потенциальных неприятностей, с
this
связанных. Если вы хотите освежить свои знания о
this
,
вот и
вот материалы, которые вам в этом помогут.
Есть идея
Начнём мы с того, что в JavaScript функции — это объекты первого класса. Так, их можно передавать другим функциям в качестве параметров и возвращать из других функций. В последнем случае, при возврате одной функции из другой, создаётся замыкание. Замыкание — это внутренняя функция, которая имеет доступ к цепочке областей видимости переменных внешней функции. В частности, речь идёт о переменных, объявленных во внешней функции, которые недоступны напрямую из области видимости, которая включает в себя эту внешнюю функцию. Вот, например, функция, которая добавляет переданное ей число к переменной, хранящейся в замыкании:
function makeAdder(base) {
let current = base;
return function(addition) {
current += addition;
return current;
}
}
Функция
makeAdder()
принимает параметр
base
и возвращает другую функцию. Эта функция принимает числовой параметр. Кроме того, у неё есть доступ к переменной
current
. Когда её вызывают, она прибавляет переданное ей число к
current
и возвращает результат. Между вызовами значение переменной
current
сохраняется.
Тут важно обратить внимание на то, что замыкания определяют собственные локальные лексические области видимости. Функции, определённые в замыканиях, оказываются в закрытом от посторонних пространстве.
Замыкания — весьма мощная возможность JavaScript. Их корректное использование позволяет создавать сложные программные системы с надёжно разделёнными уровнями абстракции.
Выше мы возвращали из функции другую функцию. С тем же успехом, вооружившись имеющимися у нас знаниями о замыканиях, из функции можно возвратить объект. Этот объект будет иметь доступ к локальному окружению. Фактически, его можно воспринимать как открытое API, которое даёт доступ к функциям и переменным, хранящимся в замыкании. То, что мы только что описали, называется «шаблон revealing module».
Этот шаблон проектирования позволяет явным образом указать общедоступные члены модуля, оставив все остальные приватными. Это улучшает читаемость кода и упрощает его использование.
Вот пример:
let counter = (function() {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
};
})();
counter.increment();
counter.increment();
console.log(counter.value()); // выводит в лог 2
Как видите, переменная
privateCounter
— это данные, с которыми нам надо работать, скрытые в замыкании и недоступные напрямую извне. Общедоступные методы
increment()
,
decrement()
и
value()
описывают операции, которые можно выполнять с
privateCounter
.
Теперь у нас есть всё необходимое для программирования на JavaScript без использования
this
. Рассмотрим пример.
Двухсторонняя очередь без this
Вот простой пример использования замыканий и функций без
this
. Это — реализация известной структуры данных, которая называется
двухсторонняя очередь (deque, double-ended queue ). Это —
абстрактный тип данных, который работает как
очередь, однако, расти и уменьшаться наша очередь может в двух направлениях.
Двухстороннюю очередь можно реализовать с помощью связного списка. На первый взгляд всё это может показаться сложным, но на самом деле это не так. Если вы изучите приведённый ниже пример, вы легко разберётесь с реализацией двухсторонней очереди и необходимых для её функционирования операций, но, что важнее, сможете применить изученные техники программирования в собственных проектах.
Вот список типовых операций, которые должна уметь выполнять двухсторонняя очередь:
- create: Создание нового объекта двухсторонней очереди
- isEmpty: Проверка, пуста ли очередь
- pushBack: Добавление нового элемента в конец очереди
- pushFront: Добавление нового элемента в начало очереди
- popBack: Удаление и возврат последнего элемента очереди
- popFront: Удаление и возврат первого элемента очереди
До написания кода подумаем о том, как реализовать очередь, оперируя объектами и переменными замыкания. Подготовительный этап — важная часть разработки.
Итак, для начала нужна переменная, назовём её
data
, которая будет хранить данные каждого элемента очереди. Кроме того, нам нужны указатели на первый и последний элементы, на голову и хвост очереди. Назовём их, соответственно,
head
и
tail
. Так как очередь мы создаём на основе связного списка, нам нужен способ связи элементов, поэтому для каждого элемента требуются указатели на следующий и предыдущий элементы. Назовём эти указатели
next
и
prev
. И, наконец, требуется отслеживать количество элементов в очереди. Воспользуемся для этого переменной
length
.
Теперь поговорим о группировке вышеописанных переменных. Каждому элементу очереди, узлу, нужна переменная с его данными —
data
, а также указатели на следующий и предыдущий узлы —
next
и
prev
. Исходя из этих соображений создадим объект
Node
, представляющий собой элемент очереди:
let Node = {
next: null,
prev: null,
data: null
};
Каждая очередь должна хранить указатели на собственную голову и хвост (переменные
head
и
tail
), а также сведения о собственной длине (переменная
length
). Исходя из этого, определим объект
Deque
следующим образом:
let Deque = {
head: null,
tail: null,
length: 0
};
Итак, у нас есть объект,
Node
, представляющий собой отдельный узел очереди, и объект
Deque
, представляющий саму двухстороннюю очередь. Их нужно хранить в замыкании:
module.exports = LinkedListDeque = (function() {
let Node = {
next: null,
prev: null,
data: null
};
let Deque = {
head: null,
tail: null,
length: 0
};
// тут нужно вернуть общедоступное API
})();
Теперь, после того, как переменные помещены в замыкание, можно описать метод
create()
. Он устроен довольно просто:
function create() {
return Object.create(Deque);
}
С этим методом мы разобрались. Нельзя не заметить, что очередь, которую он возвращает, не содержит ни одного элемента. Совсем скоро мы это исправим, а пока создадим метод
isEmpty()
:
function isEmpty(deque) {
return deque.length === 0
}
Этому методу мы передаём объект двухсторонней очереди,
deque
, и проверяем равняется ли его свойство
length
нулю.
Теперь пришло время метода
pushFront()
. Для того, чтобы его реализовать, надо выполнить следующие операции:
- Создать новый объект
Node
.
- Если очередь пуста, нужно установить указатели головы и хвоста очереди на новый объект
Node
.
- Если очередь не пуста, нужно взять текущий элемент очереди
head
и установить его указатель prev
на новый элемент, а указатель next
нового элемента установить на тот элемент, который записан в переменную head
. В результате первым элементом очереди станет новый объект Node
, за которым будет следовать тот элемент, который был первым до выполнения операции. Кроме того, надо не забыть обновить указатель очереди head
таким образом, чтобы он ссылался на её новый элемент.
- Увеличить длину очереди, инкрементировав её свойство
length
.
Вот как выглядит код метода
pushFront()
:
function pushFront(deque, item) {
// Создадим новый объект Node
const newNode = Object.create(Node);
newNode.data = item;
// Сохраним текущий элемент head
let oldHead = deque.head;
deque.head = newNode;
if (oldHead) {
// В этом случае в очереди есть хотя бы один элемент, поэтому присоединим новый элемент к началу очереди
oldHead.prev = newNode;
newNode.next = oldHead;
} else {// Если попадаем сюда — очередь пуста, поэтому просто запишем новый элемент в tail.
deque.tail = newNode;
}
// Обновим переменную length
deque.length += 1;
return deque;
}
Метод
pushBack()
, позволяющий добавлять элементы в конец очереди, очень похож на тот, что мы только что рассмотрели:
function pushBack(deque, item) {
// Создадим новый объект Node
const newNode = Object.create(Node);
newNode.data = item;
// Сохраним текущий элемент tail
let oldTail = deque.tail;
deque.tail = newNode;
if (oldTail) {
// В этом случае в очереди есть хотя бы один элемент, поэтому присоединим новый элемент к концу очереди
oldTail.next = newNode;
newNode.prev = oldTail;
} else {// Если попадаем сюда — очередь пуста, поэтому просто запишем новый элемент в head.
deque.head = newNode;
}
// Обновим переменную length
deque.length += 1;
return deque;
}
После того, как методы реализованы, создадим общедоступное API, которое позволяет вызывать извне методы, хранящиеся в замыкании. Сделаем это, вернув соответствующий объект:
return {
create: create,
isEmpty: isEmpty,
pushFront: pushFront,
pushBack: pushBack,
popFront: popFront,
popBack: popBack
}
Здесь, помимо тех методов, которых мы описали выше, есть и те, которые пока не созданы. Ниже мы к ним вернёмся.
Как же всем этим пользоваться? Например, так:
const LinkedListDeque = require('./lib/deque');
d = LinkedListDeque.create();
LinkedListDeque.pushFront(d, '1'); // [1]
LinkedListDeque.popFront(d); // []
LinkedListDeque.pushFront(d, '2'); // [2]
LinkedListDeque.pushFront(d, '3'); // [3]<=>[2]
LinkedListDeque.pushBack(d, '4'); // [3]<=>[2]<=>[4]
LinkedListDeque.isEmpty(d); // false
Обратите внимание на то, что у нас имеется чёткое разделение данных и операций, которые можно выполнять над этими данными. С двусторонней очередью можно работать, пользуясь методами из
LinkedListDeque
, до тех пор, пока есть работающая ссылка на неё.
Домашнее задание
Подозреваю, вы думали, что добрались до конца материала, и всё поняли, не написав ни строчки кода. Правда? Для того, чтобы добиться полного понимания того, о чём мы говорили выше, ощутить на практике предложенный подход к программированию, я советую вам выполнить следующие упражнения. Просто клонируйте мой репозиторий на
GitHub и приступайте к делу. (Решений задачек там, кстати, нету.)
- Основываясь на рассмотренных выше примерах реализации методов, создайте остальные. А именно, напишите функции
popBack()
и popFront()
, которые, соответственно, удаляют и возвращают первый и последний элементы очереди.
- В этой реализации двухсторонней очереди используется связный список. Ещё один вариант основан на обычных массивах JavaScript. Создайте все необходимые для двухсторонней очереди операции, используя массив. Назовите эту реализацию
ArrayDeque
. И помните — никаких this
и new
.
- Проанализируйте реализации двухсторонних очередей с использованием массивов и списков. Подумайте о временной и пространственной сложности используемых алгоритмов. Сравните их и запишите свои выводы.
- Ещё один способ реализации двухсторонних очередей заключается в одновременном использовании массивов и связных списков. Такую реализацию можно назвать
MixedQueue
. При таком подходе сначала создают массив фиксированного размера. Назовём его block
. Пусть его размер будет 64 элемента. В нём будут храниться элементы очереди. При попытке добавить в очередь больше 64-х элементов создают новый блок данных, который соединяют с предыдущим с помощью связного списка по модели FIFO. Реализуйте методы двухсторонней очереди, используя этот подход. Каковы преимущества и недостатки такой структуры? Запишите свои выводы.
- Эдди Османи написал книгу «Шаблоны проектирования в JavaScript». Там он говорит о недостатках шаблона revealing module. Один из них заключается в следующем. Если приватная функция модуля использует общедоступную функцию того же модуля, эту общедоступную функцию нельзя переопределить извне, пропатчить. Даже если попытаться это сделать, приватная функция всё равно будет обращаться к исходной приватной реализации общедоступной функции. То же самое касается и попытки изменения извне общедоступной переменной, доступ к которой даёт API модуля. Разработайте способ обхода этого недостатка. Подумайте о том, что такое зависимости, как инвертировать управление. Как обеспечить то, чтобы все приватные функции модуля работали с его общедоступными функциями так, чтобы у нас была возможность контролировать общедоступные функции. Запишите свои идеи.
- Напишите метод,
join
, который позволяет соединять две двухсторонних очереди. Например, вызов LinkedListDeque.join(first, second)
присоединит вторую очередь к концу первой и вернёт новую двухстороннюю очередь.
- Разработайте механизм обхода очереди, который не разрушает её и позволяет выполнять итерации по ней в цикле
for
. Для этого упражнения можете использовать итераторы ES6.
- Разработайте неразрушающий механизм обхода очереди в обратном порядке.
- Опубликуйте то, что у вас получилось, на GitHub, расскажите всем о том, что создали реализацию двухсторонней очереди без
this
, и о том, как хорошо вы во всём этом разбираетесь. Ну и про меня упомянуть не забудьте.
После того, как справитесь с этими основными заданиями, можете сделать и ещё несколько дополнительных.
- Используйте любой фреймворк для тестирования и добавьте тесты ко всем своим реализациям двухсторонних очередей. Не забудьте протестировать пограничные случаи.
- Переработайте реализацию двухсторонней очереди так, чтобы она поддерживала элементы с приоритетом. Элементам такой очереди можно назначать приоритет. Если такая очередь будет использоваться для хранения элементов без назначения им приоритета, её поведение ничем не будет отличаться от обычной. Если же элементам назначают приоритет, нужно обеспечить, чтобы после каждой операции последний элемент в списке имел бы наименьший приоритет, а первый — наибольший. Создайте тесты и для этой реализации двухсторонней очереди.
- Полином — это выражение, которое может быть записано в виде
an * x^n + an-1*x^n-1 + ... + a1x^1 + a0
. Здесь an..a0 —
это коэффициенты полинома, а n…1 —
показатели степени. Создайте реализацию структуры данных для работы с полиномами, разработайте методы для сложения, вычитания, умножения и деления полиномов. Ограничьтесь только упрощёнными полиномами. Добавьте тесты для проверки правильности решения. Обеспечьте, чтобы все методы, возвращающие результат, возвращали бы его в виде двухсторонней очереди.
- До сих пор предполагалось, что вы используете JavaScript. Выберите какой-нибудь другой язык программирования и выполните все предыдущие упражнения на нём. Это может быть Python, Go, C++, или что угодно другое.
Итоги
Надеюсь, упражнения вы выполнили и узнали с их помощью что-нибудь полезное. Если вы думаете, что преимущества отказа от использования
this
стоят усилий по переходу на новую модель программирования, взгляните на
eslint-plugin-fp. С помощью этого плагина можно автоматизировать проверки кода. И, если вы работаете в команде, прежде чем отказываться от
this
, договоритесь с коллегами, иначе, при встрече с ними, не удивляйтесь их угрюмым лицам. Хорошего вам кода!
Уважаемые читатели! Как вы относитесь к
this
в JavaScript?