Problema y Contexto Aplicable
Implementa deepClone(value). La entrada es un grafo de objetos finito perteneciente a un único realm de JavaScript. Puede contener primitivos, arreglos, objetos planos cuyo prototipo sea Object.prototype o null, Date, RegExp, Map y Set. Cada objeto de origen admitido debe tener un objeto distinto en la copia. Si dos aristas de origen apuntan al mismo objeto, las aristas de copia correspondientes deben apuntar al mismo objeto copiado. Un ciclo no debe provocar una recursión infinita.
La función también debe copiar cada propiedad de datos propia (own data property), incluidas las propiedades no enumerables y las que tienen claves de tipo symbol, preservando al mismo tiempo su descriptor. Preserva los huecos de arreglos dispersos (array holes), el valor de tiempo de un Date, el origen, los flags y lastIndex de un RegExp, tanto las claves como los valores de un Map, y los valores de un Set.
Las funciones, las propiedades de acceso (accessors), WeakMap, WeakSet, los proxies, los typed arrays, los array buffers, las instancias de clases personalizadas y los objetos integrados no listados quedan fuera del contrato y deben lanzar un TypeError. La implementación es recursiva, por lo que también asume que el anidamiento no agotará la pila de llamadas. Para datos de producción que se ajusten a los tipos clonables estructurados de la plataforma, evalúa structuredClone antes de convertir esta implementación de entrevista en una biblioteca de propósito general.
Qué Evalúa el Entrevistador
La primera señal es si el candidato define el significado de "clonación profunda" (deep clone). JavaScript no tiene una regla universal en el espacio de usuario (userland) que pueda reproducir clausuras de funciones, nodos del DOM, campos privados, proxies y cada internal slot integrado. Una respuesta sólida enumera los tipos admitidos, la semántica de las propiedades, el comportamiento ante ciclos y el comportamiento ante errores antes de escribir la recursión.
La segunda señal es reconocer un grafo de objetos en lugar de un árbol de objetos. Asignar objetos de forma recursiva puede copiar un árbol, pero no puede manejar source.self = source, y convierte incorrectamente source.a === source.b en dos copias separadas. El estado requerido no es simplemente "visitado"; es "qué copia pertenece a este objeto de origen".
La tercera señal es el orden de asignación. Asigna un destino vacío y registra el mapeo de origen a copia antes de recorrer las aristas salientes. Si los hijos se copian antes del registro, la primera arista hacia atrás (back edge) aún no encontrará un destino y la recursión continuará alrededor del ciclo.
La cuarta señal es un análisis riguroso de los límites de las propiedades y los objetos integrados. Object.entries omite las claves symbol y las propiedades no enumerables. Leer source[key] puede ejecutar un getter. Darle a un Date, Map o Set un objeto con el mismo prototipo no reproduce sus internal slots.
Preguntas para Aclarar Antes de Responder
- ¿Qué tipos se deben admitir? Los arreglos y los objetos planos son suficientes para datos con estructura JSON. Agregar
Date,RegExp,MapySetrequiere una construcción y un recorrido específicos para cada tipo. Agregar typed arrays o array buffers introduce decisiones sobre la copia de buffers y la propiedad (ownership). - ¿Los ciclos y las referencias duplicadas deben fallar, romperse o preservar la topología? Este problema los preserva, por lo que necesita un mapa de origen a copia. Un
WeakSetpuede detectar una repetición, pero no puede devolver la copia correcta. - ¿Las propiedades se refieren solo a claves de cadena enumerables, o también a símbolos, no enumerables y descriptores? Este contrato opta por lo último y rechaza los descriptores de acceso, evitando tanto la ejecución de getters como las funciones getter o setter compartidas.
- ¿Se deben copiar las clases personalizadas y las cadenas de prototipos? Este problema acepta solo objetos planos y los objetos integrados listados.
Object.create(instancePrototype)no puede reproducir campos privados ni el estado establecido por el constructor, por lo que presentar el resultado como una instancia completa sería engañoso. - ¿Se trata de un algoritmo de entrevista o de una API de producción? La implementación de entrevista demuestra un contrato y una invariante de grafos. El código de producción debe comparar la cobertura de tipos de
structuredClone, la semántica de transferencia y la pérdida de metadatos antes de elegir un serializador personalizado controlado. - ¿Cuál es la profundidad máxima de anidamiento? La recursión utiliza la pila auxiliar de
O(d). Una cadena que pueda contener cien mil objetos requiere una pila de trabajo explícita y cambia la implementación y las pruebas.
Estructura de Respuesta en 30 Segundos
"Definiré los tipos admitidos y trataré la entrada como un grafo. Los primitivos se devuelven directamente. Para cada objeto, consulto un WeakMap; en su primera visita, asigno y registro una copia vacía antes de copiar propiedades o entradas. De este modo, los ciclos y las referencias duplicadas se resuelven en una sola copia. Date y RegExp se reconstruyen, mientras que las funciones, los descriptores de acceso y los objetos no admitidos lanzan un error. El tiempo y el espacio de copia esperados son O(V + E), con una pila de recursión de O(d)".
Análisis Detallado Paso a Paso
JSON.parse(JSON.stringify(value)) solo es válido para un contrato JSON más restringido. Falla ante ciclos y cambia o pierde undefined, BigInt, símbolos, Date, RegExp, Map, Set y valores numéricos especiales. La recursión simple otorga más control, pero sin un mapeo de origen a copia continúa manejando únicamente árboles.
La invariante central es: antes de recorrer las propiedades o entradas de cualquier objeto admitido, seen.get(sourceObject) ya es igual a la copia única asignada para él.
function deepClone(input) {
const seen = new WeakMap();
function clone(value) {
if (typeof value === 'function') {
throw new TypeError('Functions are not supported');
}
if (value === null || typeof value !== 'object') {
return value;
}
if (seen.has(value)) {
return seen.get(value);
}
let result;
if (value instanceof Date) {
result = new Date(value.getTime());
seen.set(value, result);
copyOwnDataProperties(value, result);
return result;
}
if (value instanceof RegExp) {
result = new RegExp(value.source, value.flags);
result.lastIndex = value.lastIndex;
seen.set(value, result);
copyOwnDataProperties(value, result, new Set(['lastIndex']));
return result;
}
if (value instanceof Map) {
result = new Map();
seen.set(value, result);
for (const [key, item] of value) {
result.set(clone(key), clone(item));
}
copyOwnDataProperties(value, result);
return result;
}
if (value instanceof Set) {
result = new Set();
seen.set(value, result);
for (const item of value) {
result.add(clone(item));
}
copyOwnDataProperties(value, result);
return result;
}
if (Array.isArray(value)) {
result = new Array(value.length);
seen.set(value, result);
copyOwnDataProperties(value, result, new Set(['length']));
Object.defineProperty(
result,
'length',
Object.getOwnPropertyDescriptor(value, 'length'),
);
return result;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError('Unsupported object type');
}
result = Object.create(prototype);
seen.set(value, result);
copyOwnDataProperties(value, result);
return result;
}
function copyOwnDataProperties(source, target, skipped = new Set()) {
for (const key of Reflect.ownKeys(source)) {
if (skipped.has(key)) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!('value' in descriptor)) {
throw new TypeError('Accessor properties are not supported');
}
descriptor.value = clone(descriptor.value);
Object.defineProperty(target, key, descriptor);
}
}
return clone(input);
}seen debe almacenar la relación de origen a copia, no solo un bit de visitado. Supongamos que tanto source.first como source.second apuntan a shared. La primera visita asigna y registra sharedCopy; la segunda devuelve ese mismo objeto, preservando el aliasing. Si source.self apunta de nuevo a source, la raíz se registró antes de que se copiaran sus propiedades, por lo que la arista hacia atrás apunta a la copia de la raíz.
WeakMap es adecuado porque cada clave es un objeto y el algoritmo nunca necesita enumeración. Un Map regular también sería correcto dentro de una invocación y no causaría automáticamente una fuga permanente después de que la función retorne. Las claves débiles se adaptan al tiempo de vida de la asociación, pero 'evita fugas de memoria' no es una demostración de que los ciclos se manejen correctamente.
Reflect.ownKeys devuelve claves de tipo string y symbol, incluidas las propiedades no enumerables. El código lee descriptores en lugar de valores, por lo que no invoca proactivamente un getter; los descriptores de acceso fallan de acuerdo con el contrato. Reemplaza recursivamente el valor de un descriptor de datos y define la propiedad con sus flags writable, enumerable y configurable. En los arreglos, length es una propiedad especial no configurable, por lo que las otras claves se copian primero y su descriptor se restaura al final. Los huecos dispersos no se convierten accidentalmente en elementos cuyo valor sea undefined.
Date, RegExp, Map y Set contienen un estado interno al que la copia ordinaria de propiedades no puede acceder. La implementación reconstruye el valor de tiempo, el origen de la expresión regular, los flags y lastIndex, las claves y valores de mapas, y los valores de conjuntos. Cada contenedor se registra antes de la iteración, por lo que un mapa o conjunto puede participar en un ciclo. El código asume objetos del mismo realm; las comprobaciones de instanceof entre diferentes realms no son confiables y exigen una clonación de la plataforma o comprobaciones de marca (brand checks) más estrictas.
El límite se mantiene explícito. Este código no preserva el estado congelado (frozen), sellado (sealed) o no extensible, no clona la cadena de prototipos y no admite descriptores de acceso, campos privados, proxies, buffers ni objetos integrados no listados. structuredClone admite más tipos de la plataforma, ciclos e identidad duplicada, pero también excluye funciones y no preserva descriptores de propiedades, getters, setters, la cadena de prototipos ni RegExp.lastIndex. Estos contratos no son intercambiables simplemente porque ambos se llamen clonaciones profundas.
Sea V el número de objetos distintos y E el número de referencias aportadas por propiedades propias, entradas de mapa y elementos de conjunto. Bajo el supuesto habitual de rendimiento promedio para mapeos integrados, el trabajo de recorrido y el espacio de copia son O(V + E) porque cada objeto de origen se expande una vez. La pila de llamadas recursivas es O(d), donde d es la ruta anidada más larga. ECMAScript solo requiere un acceso sublineal promedio para Map, Set y WeakMap; no promete operaciones estrictas de O(1) en todas las implementaciones.
Las pruebas deben evaluar la estructura del grafo en lugar de comparar texto serializado: un autociclo; dos propiedades que comparten un mismo hijo; una clave de mapa referenciada también por otra propiedad; un conjunto que contiene un objeto compartido; un arreglo disperso; un objeto con prototipo nulo; propiedades de datos no enumerables y con clave symbol; Date; RegExp con un lastIndex distinto de cero; fallos controlados para funciones, descriptores de acceso y clases personalizadas; y aislamiento tras mutar la copia. Una cadena acíclica muy profunda también debe probar el límite de la recursión.
Respuesta de Muestra de Alta Calidad
"Delimitaré esto a un grafo de objetos finito y del mismo realm que contenga primitivos, Array, Object plano, Date, RegExp, Map y Set. Las funciones, los descriptores de acceso, las colecciones débiles, los buffers y las clases personalizadas lanzarán un error porque el problema no define una semántica de copia verificable para ellos.
La clave es preservar las relaciones de identidad de los objetos, no la recursión por sí sola. Mantengo un WeakMap<source, copy>. Cada vez que veo un objeto, consulto primero el mapa. En su primera visita, asigno una copia vacía y la registro antes de copiar propiedades o entradas. Un autociclo se resuelve entonces en la copia actual, y dos aristas dirigidas al mismo objeto de origen se resuelven en un único objeto de destino. Las claves y valores de Map y los elementos de Set utilizan la misma ruta de clonación, por lo que se preserva el aliasing entre contenedores.
Para las propiedades ordinarias utilizo Reflect.ownKeys y descriptores. Eso preserva símbolos, no enumerables y flags de descriptores de datos sin ejecutar getters silenciosamente. Date, RegExp, Map y Set reciben una reconstrucción específica para su tipo. Contando objetos distintos y aristas de referencia, el trabajo y el espacio de copia esperados son O(V + E), y la pila de llamadas es de O(d). En producción usaría structuredClone cuando su matriz de soporte sea adecuada, documentando que no preserva descriptores, prototipos ni el lastIndex de RegExp".
Errores Comunes
- Serializar y parsear JSON → los ciclos lanzan error, varios valores válidos de JavaScript se pierden o cambian, y las referencias compartidas se dividen → usa un contrato exclusivo para JSON, un algoritmo de grafos o
structuredClonesegún corresponda. - Copiar recursivamente solo arreglos y objetos → los autociclos entran en recursión infinita y las referencias repetidas se convierten en objetos separados → mapea cada objeto de origen a una sola copia.
- Copiar hijos antes de insertarlos en
seen→ la primera arista hacia atrás todavía carece de mapeo → asigna y registra la copia vacía antes de expandir las aristas. - Rastrear visitas con un
WeakSet→ detecta una repetición pero no puede indicarle al algoritmo qué copia devolver → usaWeakMap<source, copy>. - Usar
for...inoObject.entriespara cada propiedad → el primero incluye propiedades enumerables heredadas, mientras que el segundo omite símbolos y no enumerables → usa descriptores propios yReflect.ownKeyscuando el contrato los requiera. - Leer
source[key]directamente → un getter puede ejecutar efectos secundarios o lanzar un error, alterando el comportamiento observable de la clonación → inspecciona los descriptores y define una política explícita para los descriptores de acceso. - Crear cada destino con
Object.create(proto)→ los internal slots para Date, Map y Set siguen ausentes, y faltan los campos privados de clases personalizadas → reconstruye los objetos integrados admitidos y rechaza otros tipos. - Afirmar un
O(V + E)estricto → ECMAScript no garantiza operaciones en tiempo constante para Map, Set o WeakMap, y la recursión puede desbordarse → declara el supuesto de rendimiento promedio y el límite de pilaO(d).
Preguntas de Seguimiento y Respuestas
Pregunta de seguimiento 1: ¿Por qué preservar las referencias duplicadas en lugar de solo prevenir los ciclos?
La identidad puede tener significado para la aplicación. Si order.customer === cache.currentCustomer, clonarlos de forma independiente hace que esa igualdad sea falsa en la copia, y las mutaciones a través de una ruta copiada dejan de ser observables a través de la otra. Un mapeo uno a uno de origen a copia preserva tanto los ciclos como el aliasing. La prueba debe verificar copy.first === copy.second; limitarse a afirmar que la clonación no se desbordó demuestra muy poco.
Pregunta de seguimiento 2: ¿Cómo manejarías una cadena de cien mil objetos de profundidad?
Mantendría la misma invariante de seen, pero reemplazando las llamadas recursivas por una pila de trabajo explícita. Se asigna y registra una copia en el primer encuentro, y luego se insertan marcos que contienen el contenedor de origen, el contenedor de destino y las claves o entradas pendientes. Un bucle iterativo procesa esos marcos. El tiempo y el uso del heap siguen creciendo con V + E, pero el estado auxiliar se traslada de la pila de llamadas del lenguaje a una estructura de heap controlada. Date y RegExp finalizan de inmediato; Object, Array, Map y Set requieren marcos de seguimiento.
Pregunta de seguimiento 3: ¿Qué cambia si ArrayBuffer se puede copiar o transferir?
La API necesita una opción explícita. La copia asigna un buffer de igual longitud y copia los bytes. La transferencia invalida el buffer de origen, por lo que es un movimiento de propiedad con efectos secundarios y no puede ocultarse dentro de deepClone. La plataforma ya define esto mediante structuredClone(value, { transfer: [...] }). Una implementación personalizada que no pueda desacoplar el almacenamiento debería rechazar la transferencia en lugar de devolver dos vistas sobre un mismo buffer y denominar al resultado una copia profunda.
Pregunta de seguimiento 4: ¿Cómo admitirías clases personalizadas, getters y campos privados?
La reflexión general no puede leer campos privados ni reproducir clausuras. Conservar getters y setters comparte funciones y clausuras; evaluar un getter puede causar efectos secundarios. Una extensión defendible es un registro de serializadores: cada clase proporciona funciones serialize y deserialize que reconstruyen sus invariantes, y su adaptador decide si los descriptores de acceso se conservan, se evalúan o se rechazan. Sin un adaptador, lanzar un error es más seguro que crear un objeto para el cual instanceof sea verdadero mientras su estado interno está roto.