Обычно чаще, чем реже, мы объявляем переменные из расчёта, что их значение будет меняться: счётчики будут увеличиваться, массивы дополняться, объект типа Person поменяет богопротивное имя Джон на Аркадий, и т.д. Всё меняется, а const и readonly нужны только жмотам и студентам, начитавшимся Макконела.
С другой стороны полюса есть концепция immutable данных. Immutable (неизменяемые) данные подразумевают, что переменным мы присваиваем значения только по одному разу, и больше их не трогаем. Никогда.
Идея, на первый взгляд, дебильная. Но она даёт интересные преимущества.
Например, хотя мы редко задумываемся об этом, у концепции изменяемых полей есть своя цена. С ними постоянно приходится думать:
- а какое там сейчас значение?
- где это значение может поменяться и на что?
- нужное значение уже появилось, или будет после определенного момента?
- а что если значение будет меняться в несколько этапов, а в середине что-то пошло не так?
- если кто угодно может изменить поля, то как же это кэшировать?
Но самый ад с изменяемыми данными будет в многопоточном приложении. Управление совместным доступом — самая поганая задача, за которую мне время от времени приходится браться.
Посмотрим на такой очень схематичный пример. Откуда-то пришли GPS координаты gpsCoordinates, и мы хотим перевести их в оконные screenCoordinates, потому что будем рисовать:
1 2 3 4 5 6 7 8 9 10 |
var gpsCoordinates = [{lat: 43.238, lng: 78.298}, {lat: 43.011, lng: 78.001} ]; var screenCoordinates = []; for (var i = 0; i < gpsCoordinates.length; i++) { var currentCoordinate = gpsCoordinates[i]; screenCoordinates.push(GPSToScreen(currentCoordinate)) } |
В очень абстрактном случае вот, что может пойти не так во время цикла:
- Переменной gpsCoordinates присвоят другой массив. Это может произойти, например, из GPSToScreen — любовь к побочным эффектам иногда творит чудовищные артефакты. И внезапно мы перебираем новую коллекцию но в старый массив.
- Оттуда же — из gpsCoordinates удалили элемент. Теперь i индекс автоматически сместился вперед, и никто не заметил.
- Десятью строками выше кто-то, оказывается, уже объявил переменную currentCoordinate, которую мы только что два раза перезаписали. Удачи в дебаггинге!
- Если я по старой привычке напишу <= вместо < при проверке gpsCoordinates.length и тем самым проскочу границу массива, а GPSToScreen не свалится с undefined на входе, то в коллекцию результатов пойдёт вообще непонятно что и свалится с ошибкой где-нибудь в процессе рисования.
- Если GPSToScreen бросит ошибку, и после этого никто не догадается почистить полузаполненный screenCoordinates, то во время рендеринга случится приятный сюрприз.
- И т.д. В любом случае — удачи в дебаггинге!
Все эти ошибки хотя бы раз случались со мной. С read-only данными они исчезают как вид.
Вот каким можно было бы сделать предыдущий пример:
1 2 3 4 5 |
const gpsCoordinates = [{lat: 43.238, lng: 78.298}, {lat: 43.011, lng: 78.001} ]; const screenCoordinates = gpsCoordinates.map(GPSToScreen); |
Для верности gpsCoordinates можно защитить рекурсивным Object.freeze, но это на случай, если мы совсем никому не верим.
По итогу получилось меньше кода, он стал безопаснее и понятнее. Есть всего две переменные (да, я помню, что они теперь константы), и зависимость одной от другой визуально легко отслеживается. Тотальный вин.
С редактированием сложных объектов такой же принцип: если нужно изменить какое-то свойство — делаем копию объекта и меняем свойство уже в ней.
Например, есть объект Cursor, который может двигаться вверх-вниз по списку:
1 2 3 4 5 6 7 8 9 10 11 12 |
function Cursor (items, initialPosition = 0) { let position = initialPosition; this.current = () => items[position]; this.up = () => position > 0 ? --position : position; this.down = () => position < items.length - 1 ? ++position : position; } |
Как он работает:
1 2 3 4 5 6 |
const cursor = new Cursor([5, 4, 3, 2, 1]); cursor.down(), cursor.current(); //=> 4 cursor.down(), cursor.current(); //=> 3 |
А вот как бы выглядела его immutable версия:
1 2 3 4 5 6 7 8 9 10 11 12 |
function Cursor (items, initialPosition = 0) { const position = initialPosition; this.current = () => items[position]; this.up = () => position > 0 ? new Cursor(items, position - 1) : this; this.down = () => position < items.length - 1 ? new Cursor(items, position + 1) : this; } |
up и down теперь возвращают новый Cursor, и в жизни это выглядит так:
1 2 3 4 5 6 |
const cursor = new Cursor([5,4,3,2,1]); cursor.down().down().current(); // => 3 cursor.current(); // => 5 |
Теперь cursor абсолютно предсказуемый. В коде есть только одно место, где он получает своё значение, и к тому же он случайно получил fluent интерфейс. И всё ценой небольшой ломки мозга.
Почему immutable данные полезны:
- С ними код проще.
- С ними меньше багов. Сложнее код — больше багов. Проще код — меньше багов.
- Объекты создаются атомарно. То есть либо он создался успешно, либо не создался вовсе. [].map либо вернёт новый массив, либо упадёт с ошибкой.
- Уменьшается эффект временного связывания — это когда порядок инициализации и доступа к полям важен.
- Просто кэшировать — ничего же не меняется.
- Проще тестировать. У read-only объекта не там много вариантов, что с ним может произойти
- Потоки! Если приходится иметь дело с потоками, то для синхронизации доступа к read-only данным нужно просто ничего не делать.
Какие могут быть проблемы с immutable:
- Нагрузка на мозг. Это всё-таки непривычный подход, и нужно ломать устоявшиеся привычки. Но оно проходит. Привыкли же мы жить без go-to. Привыкли же?
- Нагрузка на CPU. Больше выделений памяти, больше сборки мусора, больше инициализаций объектов. Но! Есть мнение, и оно не только моё, что мы экономим на коде, который раньше приходилось писать для защиты изменяемых данных
- Нагрузка на память. Будет больше временных объектов, это да. Будет больше сборки мусора. Это тоже. Но! Так как все временные объекты — коротко живущие, то они пойдут в нулевое поколение сборщика мусора, которое чистится очень быстро. В отличие от фрагментированное первого поколения, в котором со временем оседают наши изменяемые объекты.
Конечно, immutable данные это не панацея, и голод в Африке они не искоренят. Да и совать такой подход во все сферы жизни тоже не стоит. Но избегать изменяемых переменных там, где без них можно спокойно обойтись, и делать read-only общие данные в потоках точно не повредит.
В мире Java эта идея тоже гуляет. На сравнительно недавной конференции JEEConf выступал товарищ с таким же докладом: http://jeeconf.com/program/how-immutability-helps-in-oop/ (на ТыТруба есть видео), очень книжку рекомендовал от мелкософта: «Object Thinking» и свою написал на ту же тему. Слушал я и думал: новый тренд или давно забытое старое? не так часто я такой подход встречал именно в ООП, больше свойственно функциональным ЯП, имхо.
Люто плюсую. В том числе за будничность поста. 🙂