Pull to refresh

Переосмысление deepClone

Reading time5 min
Views6.9K
Как известно в 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. Хотя в некоторых случаях это может быть необходимо.
Tags:
Hubs:
Total votes 18: ↑11 and ↓7+4
Comments13

Articles