Как известно в JavaScript объекты копируются по ссылке. Но иногда требуется сделать глубокое клонирование объекта. Многие js библиотеки предлагают для этого случая свою реализацию функции deepClone. Но, к сожалению, в большинстве библиотек не учитываются несколько важных вещей:

  • В объекте могут лежать массивы и их лучше копировать как массивы
  • В объекте могут быть поля с символом в качестве ключа
  • У полей объекта бывают дескрипторы отличные от дефолтного
  • В полях объекта могут лежать функции и их тоже нужно клонировать
  • У объекта наконец бывает прототип отличный от 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. Хотя в некоторых случаях это может быть необходимо.

Комментарии (12)


  1. Aingis
    06.09.2019 19:28

    Чего только люди не придумают, лишь бы не использовать _.cloneDeep. Там, например, учтён и кейс с TypedArray, в отличие от.


    1. bingo347 Автор
      06.09.2019 20:18

      Данная реализация как раз и появилась из-за того, что вариант lodash не устраивал. Ну и вообще в последнее время я избегаю lodash — он очень недружественный к ФП


      1. yarkov
        06.09.2019 21:29
        +1

        Это lodash недружественный? О_о


      1. Vadem
        07.09.2019 02:41

        Есть lodash/fp и Ramda, в которой есть clone.


  1. Lynn
    06.09.2019 19:52

    Первое что пришло в голову: нечисловые поля массива потеряются.


  1. Lynn
    06.09.2019 19:53
    +1

    Второе что пришло в голову: рекурсивные объекты


  1. igormich88
    06.09.2019 20:46

    Попробовал запустить:

    deepClone({
      firstName: "John",
      lastName : "Doe",
      id       : 5566,
      fullName : function() {
        return this.firstName + " " + this.lastName;
      }
    });
    Упало c «InternalError: too much recursion»


  1. morsic
    06.09.2019 21:52

    Хочется узнать, что имеет ввиду автор под фп


    1. assembled
      07.09.2019 06:02
      +1

      ФП — это когда код запутанный и непонятный, вы не знали чтоли?
      </sarcasm>


  1. esata
    06.09.2019 23:54

    Постоянное копирование довольно труднозатратная операция (особенно в JS и особенно в ФП-стиле). Как насчет того чтобы задействовать copy-on-write?


  1. assembled
    07.09.2019 06:08

    function deepClone(source) {
    	return ({
    		'object': cloneObject,
    		'function': cloneFunction
    	}[typeof source] || clonePrimitive)(source)();
    }
    Боже, какой паттерн матчинг )))


  1. flancer
    07.09.2019 09:06

    Но так как, к сожалению, многие до сих пор не могут перестроить свое мышление с процедурщины и псевдо-ООП, я объясню...

    Код, в первую очередь, должен выполнять свою функцию. А во вторую — он должен быть понятным. Хороший код не нужно объяснять, и это не зависит от привычек мышления.


    в зависимости от типа данных в source выбирается функция которая умеет его клонировать, и в нее передается сам source.
    Так же можно заметить, что возвращаемый результат вызывается как функция без параметров, прежде чем быть возвращенным пользователю. Это необходимо, так как я оборачиваю значение, в которое клонирую, в простейший функтор,

    Думаю, что это же самое можно было написать и на JavaScript, например так:


    function deepClone(source) {
        const type = typeof source;
        let fnClone = (type === 'object') ? cloneObject
            : (type === 'function') ? cloneFunction
                : clonePrimitive;
        const fnWrap = fnClone(source);
        return fnWrap();
    }