Primitive, Reference Types는 크게 3가지 차이가 있다.
let a = 1;
a = 2;
a; // 2
위 코드처럼 Primitive Type인 변수가 갖고 있는 값을 일상적으로 변경했었기 때문에 불변이라는 말은 와닿지 않을 수 있다.
앞서 변수의 재할당이 일어날 때 또 다른 메모리 공간을 할당 받아서 그 안에 새로운 값을 할당하고, 이전에 사용하던 메모리 공간은 버려진다고 했다.
좀 더 자세히 살펴보자면,
let a = 1;
이라는 statement가 실행되면 새로운 메모리 공간을 할당받아서 그 안에 1
을 넣고, a
로 하여금 그 메모리 공간을 참조, 변경할 수 있게 한다.
(메모리 공간은 운영체제에 의해 관리, 할당되므로 최소한 JS에서는 우리가 마음대로 주소값을 설정할 수도 볼 수도 없을 뿐더러, 볼 수 있다고 해도 그것이 물리적으로 실제 주소값을 가리키는 것도 아닐 것이다.)
이 메모리 공간의 주소값이 0xFFFFFFF1
라고 가정해보자.
a
에 접근했을 때, 우리는 0xFFFFFFF1
라는 메모리 공간에 접근할 것이고, 그로부터 CPU 아키텍쳐에 따라 4 or 8byte의 비트 패턴(00000000 00000000 00000000 00000001
, IEEE 754가 아닌 4byte 정수라고 가정했을 때, 1이므로)을 읽어서 그 안에 저장된 값인 1
을 얻을 수 있을 것이다.
그리고 a = 2;
라는 expression
이 실행될 때, 0xFFFFFFF1
안의 값을 변경하는 것이 아니라, 그 메모리 공간은 버려지고 새로운 메모리 공간을 할당 받는다. 0xFFFFFEE4
라는 임의의 주소값이라 가정하겠다.
전체 과정을 아래와 같이 진행된다고 생각할 수 있겠다.
a
-> 0xFFFFFFF1
- 1
0xFFFFFFF1
- 1
a
-> 0xFFFFFEE4
- 2
a
에 새로운 주소를 할당 받아 1
을 저장2
를 저장그래서 실제로 메모리 공간 내부의 값을 변경하는 것이 아닌,
새로운 메모리 공간에 새로운 값을 저장하는 개념이기 때문에 이를 Immutable, Readonly라고 설명하곤 한다.
object
타입의 경우, 내부에 여러 Primitive, Reference Types인 Property들을 가지고 있을 것이다.
const numbers = {
one: 1,
two: 2,
};
numbers.one = 10;
위와 같은 object
가 있다.
해당 object
가 0xFFFFFFE0
에 저장되어 있다고 가정해보자.
one
이라는 Property를 변경해도 numbers
는 그대로 0xFFFFFFE0
에 저장된 상태이며, 그 안에 one
의 메모리 공간만 재할당 되어 위에서 봤던 규칙이 그대로 적용될 것이다.
JS에서 string
은 Primitive Type이며 Immutable하기 때문에 재할당 시, 메모리 공간도 변경된다.
let str = "JavaScript";
str = "TypeScript";
JS는 UTF-16을 사용하기 때문에 한 문자당 최소 16bit, 2byte를 차지하며, 글자 수가 늘어나면 기하급수적으로 용량이 늘어나기 때문에 해당 메모리 공간의 시작 주소를 가리키는 방식으로 보통 구현된다.
만약 20,000자가 넘는 html
파일을 문자열로 읽어온다면, 40,000byte가 넘을 것이다.
그 때 시작 주소인 0xFFFFFFFF
만 저장하고 그 주소로부터 40,000byte를 읽어 오면 64bit CPU 기준 주소 공간을 담을 8byte만 저장하면 되기 때문에 보다 효율적으로 메모리를 사용할 수 있을 것이다.
C언어에서는 문자열이 아닌 char
배열을 사용하여 시작 주소만 저장하고 char
배열의 맨 뒤에 '\0'
을 항상 삽입하여 해당 문자를 찾을 때까지 읽는 방식을 사용한다.
하지만 해당 방식은 실수로 '\0'
이 누락되거나 덮어 쓰게 되는 경우, 혹은 문자열을 복사할 때, '\0'
을 제외하고 복사하는 예외적인 함수 등에 의해 디버깅하기 매우 어려운 버그가 발생한다는 문제점이 있다고 하여 최근의 언어들은 이를 또 다른 built-in 객체로 만들어서 그 안에 문자열의 길이(len), 할당된 바이트(cap), 문자열의 시작 주소 등을 저장하는 방식으로 사용하는 경우가 많다.
JS에서는 어떻게 구현이 되어 있는지 궁금하여 ECMAScript 스펙을 찾아보았다.
내용은 다음과 같다.
4.3.16 String value
primitive value that is a finite ordered sequence of zero or more 16-bit unsigned integer
A String value is a member of the String type. Each integer value in the sequence usually represents a single 16-bit unit of UTF-16 text. However, ECMAScript does not place any restrictions or requirements on the values except that they must be 16-bit unsigned integers.
unsiged int 16
타입의 ordered byte sequence인 Primitive Value이며, 그 점을 제외하면 다른 요구, 강제 사항은 없다.
그러니까 기본 사항만 충족하면 JS 인터프리터 개발사 따라 마음대로 만들라는 것 같다.
그래서 정확한 String
의 구조는 찾지 못했으나 결국 unsigned int 16[]
인 Primitive Type이고 고로 불변이므로 재할당 시마다 새로운 주소를 할당 받아 값을 넣는 형식이구나 라고 생각하기로 했다.
한가지 주의할 점은 JS에는 Primitive Type이 아닌 String
객체도 존재하는데, string
인 값에서 method를 호출하면 자동으로 Primitive -> String object로 변환된다고 한다.
그래서 기본 리터럴 값에서도 바로 method가 호출이 가능한 것이다.
"abc".length; -> 3
또한 이 특성 덕분에 다음과 같이 배열처럼 동작할 수 있다고 한다.
const str = "abcde";
str[0]; // "a" -> 배열처럼 []로 index에 접근하여 읽기 가능
str[0] = "x"; // "x" -> index 0에 접근하여 새로운 값을 할당했으나
str; // "abcde" -> 원본에 아무런 변화가 없음
str = "xxx";
str; // "xxx" -> 재할당 시 변경 가능
배열과 달리 Primitive Type, 즉 불변값이므로, 각 원소의 읽기는 가능하지만, 쓰기는 불가능
Primitive Type은 값을 변경하려면 반드시 메모리 공간 주소 자체를 변경하는 재할당을 해야한다.
let num = 100;
let numCopy = num;
num; // 100
numCopy; // 100
numCopy = 200;
num; // 100
numCopy; // 200
Primitive Type인 값을 다른 변수에 할당하면, 새로운 메모리 공간에 같은 값이 복사된다.
이를 Copy by Value, 값에 의한 전달이라 부른다.
자세히 살펴보면, let num = 100;
이라는 statement에서 0xFFFF0001
이라는 메모리 공간에 100
을 할당했다고 가정하자.
그리고 let numCopy = num;
에서, 방금 선언한 num
의 값을 numCopy
에 할당했다.
numCopy
는 새로운 메모리 공간을 갖는다.
이 주소를 0xFFFF00F0
이라고 가정하자.
최종적으로 num
은 0xFFFF0001
에 100
을, numCopy
는 0xFFFF00F0
에 100
을 갖게 되는 것이다.
거기에 numCopy
를 200
으로 재할당하면 또 다른 메모리 공간에 200
을 할당할 것이고, 기존의 주소인 0xFFFF00F0
은 버려질 것이다.
고로 numCopy
에 재할당을 해도 num
에는 아무런 영향이 없을 것이다.
이것을 Copy by Value라고 한다.
값 자체를 복사해서 새로운 메모리 공간에 할당했기 때문이다.
let a = 100;
let b = 100;
let c = 200;
a === b; // true
a === c; // false
비교 연산을 할 경우, a
와 b
는 다른 메모리 공간을 갖지만 값 자체를 비교하여 true
가 나온다.
반대로 a
와 c
는 값이 다르므로 false
가 나온다.
주의할 점은 ECMAScript 스펙에는 위와 같이 Primitive Type의 메모리 관리를 하라는 명확한 정의는 되어 있지 않기 때문에, 인터프리터 제조사에 따라 동작이 조금씩 다를 수 있다고 한다.
let a = 10;
let b = a;
어떤 엔진의 경우 위와 같은 상황에 같은 메모리 공간을 사용하도록 구현하는 경우도 있다고 한다.
그런 경우 b = 20;
처럼 재할당이 이루어 지는 경우에 b
의 메모리 공간만 바뀌도록 동작하는데,
파이썬도 이런 방식으로 동작한다고 하니 참고하도록 하자.
Reference Type인 객체는 Primitive Type과 달리 내부 Property를 변경해도 객체 자체의 메모리 주소는 바뀌지 않는다.
만일 객체의 Property가 수천 개로 늘어나고, 그러한 객체가 또 수천 개 존재한다면, 이를 모두 새로운 메모리 공간에 복사하는 것은 엄청나게 비효율적일 것이다.
아래 코드는 다음 장에서 보게 될 함수를 정의하고 호출하는 부분이다.
fn({ name: "a", name2: "b", ... });
function fn(a) {
// let a = { name: "a", name2: "b", ... };
}
fn
이라는 함수에 { name: "a", name2: "b", ... }
형태로 생긴 객체를 넣어 전달하는데, 이렇게 함수에 전달하는 인자(변수)는 함수 내부에서 새로운 변수로 생성된다.
만약 Property가 수천 개라면, 이 객체를 전달할 때 수천 개의 값을 복사해야 할 것이다.
그러나 마치 string
처럼 주소값만 복사한다면, 4 or 8byte만 복사하면 될 것이다.
object
는 사용자가 원하는 만큼 방대한 양의 Property를 추가할 수 있고, 심지어 프로그램 작동 중에 동적으로 Property를 추가할 수도 있다.
그래서 크기가 최대 어디까지 늘어날지 예상할 수 없으며, 복사했을 때 성능을 얼마나 잡아먹을지 모른다.
그렇기에 주소값만 복사해서 최대한 가볍게 전달하는 것이고 Reference Type이라 부르는 것이다.
let person1 = {
name: "a",
};
let person2 = person1;
person1.name; // "a"
person2.name; // "a"
person2.name = "x";
person1.name; // "x"
person1 === person2; // true
person1
에 새로운 object
를 할당했을 때, 주소값을 0xFFFFDD00
이라고 가정하자.
person2
에 person1
을 할당하면 새로운 주소값을 할당 받지 않고, 0xFFFFDD00
이 그대로 저장된다.
같은 공간을 가리키기에 당연히 person1.name
은 person2.name
과 같은 값이며, Primitive Type과 달리, person2.name
을 변경하면 person1.name
까지 영향을 받는다.
비교 연산자 ===
로 연산을 수행하면 true
가 나온다.
Reference Type은 내부의 값이 아닌 주소값을 비교하기 때문이다.
완전히 새로운 메모리 공간에 모든 Property가 같은 object
를 만들고 싶다면(Deep Copy) 다음과 같이 코드를 작성하면 된다.
let person1 = {
name: "a",
};
let person2 = { ...person1 };
person1 === person2; // false
person1.name; // "a"
person2.name; // "a"