Skip to main content

เจาะลึกความลับ Shallow และ Deep Copies ใน JavaScript (ตอนที่ 1)

· 10 min read

Intro

สวัสดีครับ บทความนี้มีหัวข้อเกี่ยวกับ เจาะลึกความลับ Shallow และ Deep copies ใน JavaScript ที่ผมได้พยายามอ่านและรวบรวมความเข้าใจเขียนเป็นบทความไว้ โดยมีรายละเอียดความคล้าย และความแตกต่างกันพอสมควร ซึ่งบทความมี 2 ตอนดังนี้

ในบทความนี้คุณจะได้เรียนรู้เกี่ยวกับความแตกต่างระหว่าง deep และ shallow copies ใน JavaScript ทำไมเราจึงควรใช้วิธีสร้างแบบใด และเมื่อใดควรใช้อย่างใด เพื่อหลีกเลี่ยงปัญหาที่อาจเกิดขึ้นที่เราอาจจะเข้าใจผิด เพราะความไม่รู้ และเรื่องประสิทธิภาพ

Imgur

ก่อนเริ่ม

ก่อนเริ่มเข้าสู่เนื้อหาจริง ๆ ที่พูดถึงการ copies เราควรเข้าใจก่อนว่ามีใน JavaScript มี 2 Data types ที่มีความแตกต่างกันของ Value types คือ Primitive และ Non-primitive

พื้นฐานประเภทตัวแปร

info

Key ที่สำคัญคือในทุก Programming language

  • Primitives จะ passed by value
  • Non-primitives จะ passed by reference

Primitive

Primitive เป็น Immutable (ไม่มี methods หรือ properties ที่สามารถเปลี่ยนแปลงได้)

  • string
  • number
  • booleans
  • null
  • undefined
  • bigint
  • symbol

Non-Primitive

Non-Primitive เป็น Mutable (มี methods หรือ properties ที่สามารถเปลี่ยนแปลงได้)

  • object
  • arrays
  • function

Primitive vs. Non-Primitive

Primitive

  • Primitive values มีคุณสมบัติ Immutable
  • Primitive สามารถใช้ values compared ด้วย value
  • Primitive Data types เป็น predefined
  • Primitive Data types จะมีการกำหนด values

Non-Primitive

  • Non-Primitive values มีคุณสมบัติ Mutable
  • Non-Primitive เก็บ values ด้วย Address
  • Non-Primitive สามารถ compare ด้วย reference

พิสูจน์ Non-primitives vs. Primitive

พิสูจน์ว่า String เป็น Immutable

Normal mode
const pokemon = 'Pikachu'
pokemon[0] = 'L'

console.log(pokemon) // Pikachu

จะเห็นว่าตัวแปร pokemon เมื่อเราพยายามเปลี่ยนแปลงค่าตัวอักษรแรกจาก "P" เป็น "L" แต่ค่าเดิมก็ ไม่เปลี่ยนแปลง

ทีนี้ลองมากำหนดเป็น strict mode ดูว่าจะได้รับข้อความผิดพลาด (Errors) ออกมาแบบไหน 🔥

Strict mode
'use strict'

const pokemon = 'Pikachu'
pokemon[0] = 'L'

console.log(pokemon)
// TypeError: Cannot assign to read only property '0' of string 'Pikachu'

พิสูจน์ว่า Object เป็น Mutable

Object เป็น Mutable
const pokemon = ['P', 'i', 'k', 'a', 'c', 'h', 'u']
pokemon[0] = 'L'

console.log(pokemon) // ['L', 'i', 'k', 'a', 'c', 'h', 'u']

เพราะ pokemon เป็นประเภท Non-Primitive ผลคือ สามารถเปลี่ยนค่าได้

ปัญหาการ Copy

Primitive

ทีนี้มาลอง Copy ตัวแปรประเภท Primitive แบบท่าเบสิก ๆ โดยเราไม่ต้องทำอะไรมาก มาดูผลลัพธ์

Copy ตัวแปรประเภท Primitive
const pokemon = 'Pikachu'
const _pokemon = pokemon

console.log(language) // Pikachu
console.log(_language) // Pikachu

ผลลัพธ์โอเค ✅ และควรจะเป็นอย่างงั้น ไม่มีปัญหาอะไร 😄

Non-primitive

ทีนี้ลอง Copy ตัวแปรประเภท Non-primitive และมาดูผลลัพธ์

Copy ตัวแปรประเภท Non-primitive
const pokemon = { name: 'Pikachu', age: 3 }
const _pokemon = pokemon

console.log(pokemon) // { name: 'Pikachu', age: 3 }
console.log(_pokemon) // { name: 'Pikachu', age: 3 }

ก็ปกติ! ✅ จากด้านบนเป็นตัวแปรชนิด Object จากนั้นก็ Copy ค่าให้ตัวแปรใหม่ ทุกอย่างก็ดูจะโอเค แต่ในความเป็นจริงมันไม่ใช่อย่างนั้นเสมอไป ทำไมละ ? มาดูปัญหาจากตัวอย่างด้านล่างนี้ ⚠️

จะเรียกว่าปัญหาของ Non-primitive ก็ไม่แฟร์
const pokemon = { name: 'Pikachu', age: 3 }
const _pokemon = pokemon

_pokemon.age = 5

console.log(pokemon) // { name: 'Pikachu', age: 5 }
console.log(_pokemon) // { name: 'Pikachu', age: 5 }

จากโค้ดด้านบน และดูที่ผลลัพธ์ที่ได้ pokemon ทำไมเปลี่ยนค่าได้ ? ทำไมมันเป็นอย่างนั้น ? 😅

ค่า age ที่กำหนดให้กับ _pokemon ส่งผลกระทบกับ pokemon ด้วยมันเป็นอย่างนั้นได้อย่างไร ?

note

จะบอกว่านี่คือปัญหาของ Non-primitive ก็ไม่แฟร์ ❌ แต่อยากให้มองว่ามันเป็นจุดเด่นมากกว่า 😎 เพราะมันเป็นเรื่อง stack vs. heap

stack vs. heap

ก่อนจะไปทำความเข้าใจ ก่อนอื่นคุณต้องเข้าใจเรื่องพื้นฐานของ Computer science ก่อน

  • จากที่เราเคยเรียนมา ในคอมพิวเตอร์จะมีตัวแปรอยู่ชนิดหนึ่งชื่อ Pointer เป็นตัวแปรที่ทำหน้าที่ชี้ไปที่ Value และเก็บ Address
  • ในภาษา Programming languages มี 2 สิ่งที่เก็บข้อมูล Computer memory คือ stack และ heap
  • โดย stack คือ Temporary memory ที่เก็บข้อมูล Primitive Local variables และ References to objects
  • และ heap คือตัวที่ทำหน้าที่เก็บข้อมูล Global variables ซึ่ง heap และ stacks จะเชื่อมโยงหากันด้วย (pointers) ดังรูปภาพด้านล่าง

  • จากรูปภาพด้านบนตัวแปร pokemon และ _pokemon โดยใช้ค่าเดียวกัน
    • ทั้ง 2 ตัวแปรได้ แยกการจัดเก็บข้อมูล เป็น 2 ชุดบน stack
    • และทั้ง 2 ตัวแปร ไม่มีการเชื่อมโยงหาระหว่างกัน
    • ทั้ง 2 ตัวแปร คือ Primitives เป็นแบบ "Passed by value"
  • ต่อมาได้สร้างตัวแปร 2 Objects ด้วยค่าที่เหมือนกัน จะเห็นว่ามี 2 pointers (references) ใน stack
    • pointers ชี้ไปที่ address เดียวกัน
    • จะมี 1 ค่าเท่านั้นที่อยู่บน heap
    • ซึ่งเราสรุปได้ว่ามันคือ "passed by reference"

ลองคิดต่อสมมุติว่า ถ้าเราต้องการเปลี่ยนแปลงค่า แค่บาง Properties ของ Object แต่ไม่ให้มันกระทบกับ Object อีกตัว มันไม่สามารถทำได้! ❌

นั่นเป็นเพราะปัญหาก็คือ การใช้ heap ร่วมกัน

นี่ก็เป็นเหตุผลว่าทำไมต้องทำ Deep copy และ Shallow copy 💡

Shallow copy

ก่อนจะใช้ Shallow copy มาเข้าใจก่อนว่า

  • Shallow copy เป็นการใช้สำหรับ "flat" objects
  • Deep copy เป็นการใช้สำหรับ "nested" objects

โดยคำว่า "flat" objects ก็หมายความว่า object ที่ประกอบไปด้วย primitive values เช่น

const numbers = [ 1, 2, 3, 4, 5, 6 ]

คำว่า "nested" objects ก็หมายความว่า object ที่ประกอบไปด้วย non-primitive values เช่น

const pokemon = [ "Pikachu", { type: "Electric type" } ]

ทีนี้มาดู Methods ต่าง ๆ ที่ไว้สร้าง Shallow copy

Spread syntax

วิธีการสร้าง Shallow copy ด้วย Spread syntax

Spread syntax แก้ไขค่าของ Shallow copy ไม่กระทบ Object ต้นฉบับ
const numbers = [ 1, 2, 3, 4, 5, 6 ]
const _numbers = [ ...numbers ]

_numbers[0] = 7

console.log(numbers) //[ 1, 2, 3, 4, 5, 6 ]
console.log(_numbers) //[ 7, 2, 3, 4, 5, 6 ]
Spread syntax แก้ปัญหาการ Copy ของก่อนหน้า
const pokemon = { name: 'Pikachu', age: 3 }
const _pokemon = { ...pokemon }

_pokemon.age = 5

console.log(pokemon) // { name: 'Pikachu', age: 3 }
console.log(_pokemon) // { name: 'Pikachu', age: 5 }
info

{...} Spread syntax สามารถอ่านเพิ่มเติมได้ที่นี่ Rest Parameters vs. Spread Operators ใน JavaScript

Array.prototype.concat

นอกจากนี้ยังมี Method ตัวหนึ่งที่คล้าย ๆ กับ Spread syntax คือ concat() method (แต่ใช้ได้แค่กับ Array) ตัวอย่าง

concat() method
const number1 = [ 1, 2, 3 ]
const number2 = [ 4, 5, 6 ]

const numberAll = number1.concat(number2)
console.log(number1) // [ 1, 2, 3 ]
console.log(numberAll) // [ 1, 2, 3, 4, 5, 6 ]
//

const pokemon1 = { name: 'Pikachu', age: 3 }
const pokemon2 = { name: 'Venusaur', age: 4 }

const pokemonArr1 = [ pokemon1 ]
const pokemonArr2 = [ pokemon2 ]

// by Concat method
const pokemonAll1 = pokemonArr1.concat(pokemonArr2)
console.log(pokemonAll1) // [ { name: 'Pikachu', age: 3 }, { name: 'Venusaur', age: 4 } ]

// by Spread syntax
const pokemonAll2 = [ ...pokemon1, ...pokemon2 ]
console.log(pokemonAll2) // [ { name: 'Pikachu', age: 3 }, { name: 'Venusaur', age: 4 } ]

// Spread syntax (merge)
const pokemonAll3 = { ...pokemon1, ...pokemon2 }
console.log(pokemonAll3) // { name: 'Venusaur', age: 4 }
console.log(pokemon1) // { name: 'Pikachu', age: 3 }

ก็จะเห็นว่ามีความคล้าย ๆ กันแต่ ต้องระวังการใช้งานให้ดี ใช้ด้วยความเข้าใจ

ข้อดีการใช้ concat คือเมื่อทำงานกับ Array ขนาดใหญ่ concat นั้นมีประสิทธิภาพดีกว่า Spread syntax

เนื่องจากบางทีก็จะเกิด Errors จากการใช้งาน Spread syntax กับ Array ขนาดใหญ่ เช่น Maximum call stack size excited และในทางกลับกัน Spread syntax ก็มีประสิทธิภาพที่ดีกับ Array ขนาดเล็ก

Object.assign

Object.assign() เป็น method ที่ใช้งานกับ Object มีความสามารถในการ copy จาก source object ไปที่ target object ที่มีความ คล้ายกับ Spread syntax แต่ก็ยังมีอะไรบางอย่างที่แตกต่างกันอยู่บ้างมาดูกัน

Object.assign
const sourceObj = { a: 3, b: 4 }
const targetObj = { b: 5, c: 6 }
const result = Object.assign(targetObj, sourceObj)

console.log(sourceObj) // {a: 3, b: 4}
console.log(targetObj) // {b: 4, c: 6, a: 3}
console.log(result) // {b: 4, c: 6, a: 3}
Spread syntax
const sourceObj = { a: 3, b: 4 }
const targetObj = { b: 5, c: 6 }
const result = {...targetObj, ...sourceObj}

console.log(sourceObj) // {a: 3, b: 4}
console.log(targetObj) // {b: 5, c: 6}
console.log(result) // {b: 4, c: 6, a: 3}
note

ข้อแตกต่างหลักของ Object.assign vs. Spread syntax คือ Object.assign มีความสามารถ mutates กับ Target object

ถ้าเราต้องการใช้ Object.assign แต่ไม่ต้องการให้ทำ mutates กับ Object ใด ๆ เราสามารถเพิ่ม Empty object () ไว้ที่ Argument แรกของ Method ตัวอย่าง

Object.assign with Empty object
const sourceObj = { a: 3, b: 4 }
const targetObj = { b: 5, c: 6 }
const result = Object.assign({}, targetObj, sourceObj)

console.log(sourceObj) // {a: 3, b: 4}
console.log(targetObj) // {b: 5, c: 6}
console.log(result) // {b: 4, c: 6, a: 3}

Array.from

Array.from() คือ Static method ที่จะสร้าง New array จาก Array ใด ๆ มาดูตัวอย่างเริ่มจากง่าย ๆ ไปยาก

Array.from Ex.0
const set = [1, { a: 3 }, 1, 'c', { a: 3 }];
const result = Array.from(set);

result[0] = 2;

console.log(set); // [ 1, { a: 3 }, 1, 'c', { a: 3 } ]
console.log(result); // [ 2, { a: 3 }, 1, 'c', { a: 3 } ]

สร้างด้วย Set

Array.from Ex.1
const set = new Set([1, { a: 3 }, 1, 'c', { a: 3 }])
const result = Array.from(set)

console.log(set) // {1, { a: 3 }, 'c', { a: 3 }}
console.log(result) // [1, { a: 3 }, 'c', { a: 3 }]
note

เมื่อสร้างด้วย Set ก็จะลบค่าที่ ซ้ำกัน ออกจาก Array (จะลบประเภท Primitive values เท่านั้น ✅)

ตัวอย่างสุดท้าย

Array.from Ex.2
const obj1 = [ 3, 4, 'a', 'b', 6 ]
const obj2 = [ 3, 4, 5, 'a', 'b', ]
const set = new Set([...obj1, ...obj2])
const result = Array.from(set)

console.log(obj1) // [3, 4, 'a', 'b', 6]
console.log(obj2) // [3, 4, 5, 'a', 'b']
console.log(set) // {3, 4, 'a', 'b', 6, 5}
console.log(result) // [3, 4, 'a', 'b', 6, 5]
tip

Set() นั้นสามารถเอามาประยุกต์ใช้สำหรับ ลบค่าที่ซ้ำกัน ออกจาก Array ได้ 🚀

Object.create

วิธีสุดท้ายสำหรับสร้าง Shallow copy ด้วย Object.create() เป็น Method ที่สร้าง Object ใหม่จาก Object ใด ๆ รับ 2 Arguments คือ Prototype และ Properties (Optional)

Object.create
const pokemon = { name: 'Venusaur', age: 4 }
const _pokemon = Object.create(pokemon)

console.log(_pokemon)
// {}
console.log({ name: _pokemon.name, age: _pokemon.age })
// { name: 'Venusaur', age: 4 }
_pokemon.age = 5
console.log({ name: _pokemon.name, age: _pokemon.age })
// { name: 'Venusaur', age: 5 }
console.log(pokemon)
// { name: 'Venusaur', age: 4 }
info

เรื่องที่ควรรู้เมื่อใช้ Object.create() เวลาใช้ console.log(_pokemon) ค่าจะออกมาเป็น {} (Empty)

ไม่ต้องตกใจ เพราะ Object.create จะเป็นการสร้าง Object ด้วย Prototype และ Properties ดังนั้นสามารถเข้าถึง Properties ได้ปกติ เช่น

  • _pokemon.name
  • _pokemon.age
note

เมื่อใช้ Object.create() ถึงแม้ไม่สามารถ console.log(_pokemon) ออกมาดูค่าได้ตรง ๆ เนื่องจากอย่างที่บอกเพราะเป็นค่า Prototype ดังนั้นต้องใช้วิธีนี้

console.log(_pokemon.__proto__) แต่จะมีข้อแตกต่างเล็กน้อยดังนี้

มีข้อแตกต่างเล็กน้อย เมื่อใช้ __proto__
const pokemon = { name: 'Venusaur', age: 4 };
const _pokemon = Object.create(pokemon);

_pokemon.age = 5;
console.log({ name: _pokemon.name, age: _pokemon.age });
// { name: 'Venusaur', age: 5 }
console.log(_pokemon.__proto__);
// { name: 'Venusaur', age: 4 }
console.log(pokemon);
// { name: 'Venusaur', age: 4 }

Deep copy

ก่อนจะไปเข้าใจ Deep copy และดูเหมือนว่า Shallow copy ก็ใช้งานเพียงพอแล้ว แต่มาดูปัญหานี้ก่อน

ปัญหา Shallow copy
const pokemon = {
name: 'Pikachu',
age: 4,
properties: { type: 'Electric' },
};
const _pokemon = { ...pokemon };

_pokemon.name = 'Venusaur'
_pokemon.age = 5;
_pokemon.properties.type = 'Grass & Poison';

console.log(pokemon);
//{ name: 'Pikachu', age: 4, properties: { type: 'Grass & Poison' } }
console.log(_pokemon);
//{ name: 'Venusaur', age: 5, properties: { type: 'Grass & Poison' } }

จากผลลัพธ์ เห็นอะไรแปลก ๆ ไหม ? pokemon Properties ถูกแก้ไขได้ ...แต่มันไม่ควรเป็นแบบนั้น! ❌

อย่าลืม อย่างที่บอกไว้ในหัวข้อแรก ๆ เพราะ (ทวนอีกรอบ)

  • Shallow copy เป็นการใช้สำหรับ "flat" objects
  • Deep copy เป็นการใช้สำหรับ "nested" objects

ทีนี้มาดูปัญหานี้ด้วย Deep copy ด้วยวิธีดังต่อไปนี้

  • JSON.parse(JSON.stringify())
  • structuredClone()
  • Third party libraries ด้วย Lodash

JSON.parse & structuredClone

Deep copy ด้วยวิธี JSON.parse(JSON.stringify()) และ structuredClone() เมื่อต้องการคัดลอก "nested" objects โดยเป็น Native methods ไม่จำเป็นต้องพึ่งพา Libraries

JSON.parse & structuredClone
const pokemon = {
name: 'Pikachu',
age: 4,
properties: { type: 'Electric' },
};
// JSON.parse
const _pokemon = JSON.parse(JSON.stringify(pokemon))
// structuredClone
const _pokemon2 = structuredClone(pokemon)

_pokemon.name = 'Venusaur';
_pokemon.age = 5;
_pokemon.properties.type = 'Grass & Poison';

_pokemon.name = 'Eevee';
_pokemon2.age = 3;
_pokemon2.properties.type = 'Normal';

console.log(pokemon);
// { name: 'Pikachu', age: 4, properties: { type: 'Electric' } }
console.log(_pokemon)
// { name: 'Venusaur', age: 5, properties: { type: 'Grass & Poison' } }
console.log(_pokemon2);
// { name: 'Eevee', age: 3, properties: { type: 'Normal' } }

สรุป

จบแล้วสำหรับ ตอนที่ 1 Shallow copy และ Deep copy ดูเหมือนเรื่อง Shallow copy และ Deep copy จะมีจักรวาลเกี่ยวข้องกันหลายเรื่อง 😅

เพราะถ้าไม่เข้าใจ ก็เลือกใช้งานให้ไม่ถูก และก่อนจะใช้ Shallow copy ต้องเข้าใจก่อนว่า

  • Shallow copy เป็นการใช้สำหรับ "flat" objects
  • Deep copy เป็นการใช้สำหรับ "nested" objects

flat vs. nested

  • โดยคำว่า "flat" objects ก็หมายความว่า object ที่ประกอบไปด้วย primitive values
  • ส่วนคำว่า "nested" objects ก็หมายความว่า object ที่ประกอบไปด้วย non-primitive values

อีกทั้งต้องเข้าใจว่า Primitive กับ Non-primitive คืออะไรต่างกันอย่างไร

สุดท้ายอย่าลืมเรื่อง stack และ heap และบทความตอนที่ 2 จะพาไปทดลองเพื่อทำความเข้าใจมากขึ้น

References

Loading...