Как известно в JavaScript объекты копируются по ссылке. Но иногда требуется сделать глубокое клонирование объекта. Многие js библиотеки предлагают для этого случая свою реализацию функции deepClone. Но, к сожалению, в большинстве библиотек не учитываются несколько важных вещей:
Моя реализация написана в функциональном стиле, который обеспечивает мне надежность, стабильность и простоту. Но так как, к сожалению, многие до сих пор не могут перестроить свое мышление с процедурщины и псевдо-ООП, я объясню каждый строительный кирпичек моей реализации:
Сама функция deepClone будет принимать 1 аргумент source — источник из которого будем клонировать, а возвращать будет его глубокий клон со всеми указанными выше особенностями:
Тут все просто, в зависимости от типа данных в source выбирается функция которая умеет его клонировать, и в нее передается сам source.
Так же можно заметить, что возвращаемый результат вызывается как функция без параметров, прежде чем быть возвращенным пользователю. Это необходимо, так как я оборачиваю значение, в которое клонирую, в простейший функтор, дабы иметь возможность его мутировать не нарушая чистоту вспомогательных функций. Вот реализация этого функтора:
Он умеет делать 2 вещи — map (если ему передана функция mapper) и extract (если ничего не передано).
Теперь разберем сами вспомогательные функции cloneObject, cloneFunction и clonePrimitive. Каждая из них принимает 1 аргумент source конкретного типа и возвращает его клон.
Реализация cloneObject должна учитывать, что массивы имеют так же тип object, ну а в других случаях должна клонировать поля и прототип. Вот ее реализация:
Массив можно скопировать с помощью метода slice, но так как у нас глубокое клонирование, и массив может содержать не только примитивные значения, используется метод map с описанной выше deepClone в качестве аргумента.
Для других же объектов мы создаем новый объект и оборачиваем его в наш функтор описанный выше, клонируем поля (вместе с дескрипторами) с помощью вспомогательной функции cloneFields, а затем клонируем прототип с помощью clonePrototype.
Вспомогательные функции я опишу ниже. А пока рассмотрим реализацию cloneFunction:
Просто склонировать функцию со всей логикой нельзя. Но можно обернуть ее в другую функцию, которая вызывает исходную со всеми аргументами и контекстом, и возвращает ее результат. Такой «клон» конечно будет удерживать исходную функцию в памяти, зато сам будет «весить» мало и полностью воспроизведет исходную логику. Клонированную функцию завернем в функтор и используя cloneFields скопируем в него все поля из исходной функции, так как функция в JS это тоже объект, просто вызываемый, а следовательно может хранить в себе поля.
Потенциально у функции может быть и прототип отличный от Function.prototype, но я не стал рассматривать этот крайний случай. Одна из прелестей ФП в том, что мы легко можем добавить новую обертку над существующей функцией, чтобы реализовать необходимый функционал.
Последний строительный кирп��чек clonePrimitive служит для клонирования примитивных значений. Но так как примитивные значения копируются по значению (или по ссылке, но являются иммутабельными в некоторых реализациях JS движков), мы можем просто их скопировать. Но так как от нас ждут не чистое значение, а значение обернутое в функтор, который умеет extract при вызове без аргументов, то мы обернем наше значение в функцию:
Теперь реализуем вспомогательные функции, которые использовались выше — clonePrototype и cloneFields
Для клонирования прототипа clonePrototype будет просто извлекать прототип из исходного объекта и, совершая map операцию над полученным функтором, устанавливать его в целевой объект:
С клонированием полей все немного сложнее, поэтому я разбил cloneFields функцию на две. Внешняя функция берет конкатенацию всех именованных полей и всех символьных полей, получая абсолютно все поля, и прогоняет их через редьюсер созданный вспомогательной функцией:
makeCloneFieldReducer должна создать нам функцию-редьюсер, которую можно было бы отдать в метод reduce на массиве всех полей исходного объекта. В качестве аккумулятора будет использоваться наш функтор, хранящий целевой объект. Редьюсер должен извлечь дескриптор из поля исходного объекта и назначить его в поле целевого объекта. Но тут важно учесть, что дескрипторы бывают двух видов — с value и с get/set. Очевидно, что value нужно клонировать, а вот с get/set такой потребности нет, такой дескриптор можно отдать как есть:
Вот и все. Такая реализация deepClone решает все поставленные в начале статьи проблемы. Кроме того, она построена на чистых функциях и одном функторе, что дает все гарантии присущие лямда исчислению.
Так же замечу, что я не стал реализовывать отличное поведение для других коллекций кроме массива, которые стоило бы клонировать индивидуально, такие как Map или Set. Хотя в некоторых случаях это может быть необходимо.
- В объекте могут лежать массивы и их лучше копировать как массивы
- В объекте могут быть поля с символом в качестве ключа
- У полей объекта бывают дескрипторы отличные от дефолтного
- В полях объекта могут лежать функции и их тоже нужно клонировать
- У объекта наконец бывает прототип отличный от Object.prototype
Кому влом читать, поместил под спойлер полный код
function deepClone(source) {
return ({
'object': cloneObject,
'function': cloneFunction
}[typeof source] || clonePrimitive)(source)();
}
function cloneObject(source) {
return (Array.isArray(source)
? () => source.map(deepClone)
: clonePrototype(source, cloneFields(source, simpleFunctor({})))
);
}
function cloneFunction(source) {
return cloneFields(source, simpleFunctor(function() {
return source.apply(this, arguments);
}));
}
function clonePrimitive(source) {
return () => source;
}
function simpleFunctor(value) {
return mapper => mapper ? simpleFunctor(mapper(value)) : value;
}
function makeCloneFieldReducer(source) {
return (destinationFunctor, field) => {
const descriptor = Object.getOwnPropertyDescriptor(source, field);
return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? {
...descriptor,
value: deepClone(descriptor.value)
} : descriptor));
};
}
function cloneFields(source, destinationFunctor) {
return (Object.getOwnPropertyNames(source)
.concat(Object.getOwnPropertySymbols(source))
.reduce(makeCloneFieldReducer(source), destinationFunctor)
);
}
function clonePrototype(source, destinationFunctor) {
return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source)));
}
Моя реализация написана в функциональном стиле, который обеспечивает мне надежность, стабильность и простоту. Но так как, к сожалению, многие до сих пор не могут перестроить свое мышление с процедурщины и псевдо-ООП, я объясню каждый строительный кирпичек моей реализации:
Сама функция deepClone будет принимать 1 аргумент source — источник из которого будем клонировать, а возвращать будет его глубокий клон со всеми указанными выше особенностями:
function deepClone(source) {
return ({
'object': cloneObject,
'function': cloneFunction
}[typeof source] || clonePrimitive)(source)();
}
Тут все просто, в зависимости от типа данных в source выбирается функция которая умеет его клонировать, и в нее передается сам source.
Так же можно заметить, что возвращаемый результат вызывается как функция без параметров, прежде чем быть возвращенным пользователю. Это необходимо, так как я оборачиваю значение, в которое клонирую, в простейший функтор, дабы иметь возможность его мутировать не нарушая чистоту вспомогательных функций. Вот реализация этого функтора:
function simpleFunctor(value) {
return mapper => mapper ? simpleFunctor(mapper(value)) : value;
}
Он умеет делать 2 вещи — map (если ему передана функция mapper) и extract (если ничего не передано).
Теперь разберем сами вспомогательные функции cloneObject, cloneFunction и clonePrimitive. Каждая из них принимает 1 аргумент source конкретного типа и возвращает его клон.
Реализация cloneObject должна учитывать, что массивы имеют так же тип object, ну а в других случаях должна клонировать поля и прототип. Вот ее реализация:
function cloneObject(source) {
return (Array.isArray(source)
? () => source.map(deepClone)
: clonePrototype(source, cloneFields(source, simpleFunctor({})))
);
}
Массив можно скопировать с помощью метода slice, но так как у нас глубокое клонирование, и массив может содержать не только примитивные значения, используется метод map с описанной выше deepClone в качестве аргумента.
Для других же объектов мы создаем новый объект и оборачиваем его в наш функтор описанный выше, клонируем поля (вместе с дескрипторами) с помощью вспомогательной функции cloneFields, а затем клонируем прототип с помощью clonePrototype.
Вспомогательные функции я опишу ниже. А пока рассмотрим реализацию cloneFunction:
function cloneFunction(source) {
return cloneFields(source, simpleFunctor(function() {
return source.apply(this, arguments);
}));
}
Просто склонировать функцию со всей логикой нельзя. Но можно обернуть ее в другую функцию, которая вызывает исходную со всеми аргументами и контекстом, и возвращает ее результат. Такой «клон» конечно будет удерживать исходную функцию в памяти, зато сам будет «весить» мало и полностью воспроизведет исходную логику. Клонированную функцию завернем в функтор и используя cloneFields скопируем в него все поля из исходной функции, так как функция в JS это тоже объект, просто вызываемый, а следовательно может хранить в себе поля.
Потенциально у функции может быть и прототип отличный от Function.prototype, но я не стал рассматривать этот крайний случай. Одна из прелестей ФП в том, что мы легко можем добавить новую обертку над существующей функцией, чтобы реализовать необходимый функционал.
Последний строительный кирп��чек clonePrimitive служит для клонирования примитивных значений. Но так как примитивные значения копируются по значению (или по ссылке, но являются иммутабельными в некоторых реализациях JS движков), мы можем просто их скопировать. Но так как от нас ждут не чистое значение, а значение обернутое в функтор, который умеет extract при вызове без аргументов, то мы обернем наше значение в функцию:
function clonePrimitive(source) {
return () => source;
}
Теперь реализуем вспомогательные функции, которые использовались выше — clonePrototype и cloneFields
Для клонирования прототипа clonePrototype будет просто извлекать прототип из исходного объекта и, совершая map операцию над полученным функтором, устанавливать его в целевой объект:
function clonePrototype(source, destinationFunctor) {
return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source)));
}
С клонированием полей все немного сложнее, поэтому я разбил cloneFields функцию на две. Внешняя функция берет конкатенацию всех именованных полей и всех символьных полей, получая абсолютно все поля, и прогоняет их через редьюсер созданный вспомогательной функцией:
function cloneFields(source, destinationFunctor) {
return (Object.getOwnPropertyNames(source)
.concat(Object.getOwnPropertySymbols(source))
.reduce(makeCloneFieldReducer(source), destinationFunctor)
);
}
makeCloneFieldReducer должна создать нам функцию-редьюсер, которую можно было бы отдать в метод reduce на массиве всех полей исходного объекта. В качестве аккумулятора будет использоваться наш функтор, хранящий целевой объект. Редьюсер должен извлечь дескриптор из поля исходного объекта и назначить его в поле целевого объекта. Но тут важно учесть, что дескрипторы бывают двух видов — с value и с get/set. Очевидно, что value нужно клонировать, а вот с get/set такой потребности нет, такой дескриптор можно отдать как есть:
function makeCloneFieldReducer(source) {
return (destinationFunctor, field) => {
const descriptor = Object.getOwnPropertyDescriptor(source, field);
return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? {
...descriptor,
value: deepClone(descriptor.value)
} : descriptor));
};
}
Вот и все. Такая реализация deepClone решает все поставленные в начале статьи проблемы. Кроме того, она построена на чистых функциях и одном функторе, что дает все гарантии присущие лямда исчислению.
Так же замечу, что я не стал реализовывать отличное поведение для других коллекций кроме массива, которые стоило бы клонировать индивидуально, такие как Map или Set. Хотя в некоторых случаях это может быть необходимо.
