{tocify} $title={Table of Content}
JavaScript types
Types trong Javascript có thể được chia làm 2 nhóm: primitive values và non-primitive values (hay objects hoặc reference values). Việc phận biệt giữa primitive values và objects là một điều quan trọng vì chúng hoạt động và sử dụng bộ nhớ theo các cách khác nhau.
Primitive values (nguyên thủy)
Javascript có 7 kiểu dữ liệu thuộc nhóm này, bao gồm: boolean, null, undefined, string, number, bigint và symbol.
Kích thước của một primitive value là cố định, do đó JavaScript lưu primitive value trên Stack. Và việc thực thi code cũng được diễn ra trên Stack nên khi ta gán một giá trị primitive cho một biến nào đó thì giá trị đó sẽ được lưu trực tiếp trên Stack.
Primitive values trong JavaScript là bất biến (immutable). Chúng ta có thể tham chiếu đến địa chỉ mà nó được lưu và truy cập các giá trị này NHƯNG không thể thay đổi, xóa hoặc thêm bất kỳ thứ gì vào nó - Read Only.
Lưu ý:
- Bạn cần phân biệt rõ, một primitive value và một biến được gán bằng primitive value. Biến chỉ trỏ đến địa chỉ lưu primitive value, do đó, nó có thể được gán lại với một giá trị mới NHƯNG giá trị đã tồn tại trước đó thì không thể bị thay đổi. Cũng đồng nghĩa với việc, vùng nhớ đang lưu trữ giá trị primitive gốc chỉ cấp quyền read only.
- Để thay đổi giá trị của biến, chúng ta phải gán lại cho biến đó một giá trị mới, lúc này, JavaScript sẽ cấp phát một vùng mới để lưu giá trị mới và trỏ biến đó đến vùng nhớ này. Giá trị cũ vẫn tồn tại trên Stack cho đến khi hệ thống giải phóng nó.
Để minh họa cho điều này, bạn hãy xem ví dụ dưới đây:
let name = 'Benji';
console.log(name[0]) // B
name[0] = 'K';
name.toUpperCase();
name.length = 0;
console.log(name) // Benji
Như bạn thấy, chúng ta có thể truy cập phần tử đầu tiên của Benji nhưng chúng ta không thể dùng bất cứ phương thức nào để thay đổi trực tiếp giá trị Benji của chính biến name hiện tại. Cách duy nhất để thay đổi giá trị chứa trong biến name là gán lại cho nó một giá trị khác:
let name = 'Benji';
name = 'Kenji';
console.log(name) // Kenji
name = name.toUpperCase();
console.log(name) // KENJI
Mình sẽ giải thích một chút về cách hoạt động như sau:
- Đầu tiên, chúng ta khai báo một biến name và gán cho nó một giá trị là Benji. Về cơ bản, chúng ta có thể hiểu đơn giản là name bằng Benji. Tuy nhiên về mặt kỹ thuật, biến name khi được khai báo, nó cũng chiếm một vị trí trong bộ nhớ Stack. Sau đó, khi gán name = 'Benji', JavaScript sẽ cấp phát một vùng nhớ trên Stack (0x1001) và lưu giá trị Benji vào trong đó. Tiếp theo, biến name sẽ trỏ đến vùng nhớ chứa chứa giá trị Benji (0x1001).
- Khi ta gán lại biến name cho Kenji, JavaScript tiếp tục cấp phát một vùng nhớ mới (0x1002) và lưu giá trị Kenji vào trong đó, điều này xảy ra vì primitive values trong JavaScript là bất biến. Biến name sẽ gỡ bỏ liên kết với địa chỉ (0x1001) chứa giá trị Benji trước đó và trỏ đến địa chỉ (0x1002) chứa giá trị Kenji mới. Giá trị Benji trên địa chỉ (0x1001) sẽ không có biến nào trỏ đến nó, tuy nhiên nó sẽ vẫn tồn tại cho đến khi hệ thống giải phóng nó. Sau khi vùng nhớ 0x1001 được giải phóng thì nó sẽ được tái sử dụng để lưu trữ giá trị khác.
- Hành vi tương tự cũng sẽ diễn ra khi ta gán name = name.toUpperCase(). name.toUpperCase() sẽ được thực thi trên Call Stack và trả về giá trị KENJI. JavaScript sẽ cấp phát một vùng nhớ mới (0x1003) để lưu giá trị này và trỏ biến name đến vùng nhớ này. Do đó, hiện tại name = KENJI.
Nhưng đối với các kiểu là object thì ta có thể thay đổi trực tiếp nó mà không phải thực hiện gán lại:
let names = ['A', 'B', 'C'];
names[0] = 'K';
console.log(names); // ['K', 'B', 'C']
names.length = 0;
console.log(names); // []
Các lưu ý
- Một number chiếm 8 bytes trong bộ nhớ và một giá trị boolean có thể được biểu diễn bằng 1 bit duy nhất. number là kiểu lớn nhất trong các primitive.
- Để xác định kiểu của một giá trị nguyên thủy, bạn sử dụng toán tử typeof. typeof null sẽ trả về object nhưng nếu bạn so sánh null === null thì kết quả sẽ là true, vì null vẫn được xem là primitive value và nó được lưu trên Stack.
typeof null //--> object
Đây là một BUG phát sinh từ phiên bản JavaScript đầu tiên, khi phát hiện ra thì nó đã quá muộn để fix. Do đó, typeof null là object đã ra đời, tuy nhiên nó vẫn được xem là primitive nhé. Bạn có thể đọc bài viết The history of "typeof null" tại đây để biết thêm về quả bug này nhé.
- number có 2 giá trị đặc biệt là NaN và Infinity. typeof NaN và Infinity đều trả về number nhưng ta không thể thực hiện phép so sánh với NaN (Not A Number) vì phép so sánh với NaN luôn trả về false, kể cả so sánh với chính nó NaN === NaN là false; Infinity === Infinity sẽ return true.
NaN trả về khi chúng ta thực hiện một phép toán trên một số có giá trị không phải là số. Tuy nhiên, +null = +[] = 0.
console.log(Number.NEGATIVE_INFINITY); // -Infinityconsole.log(Number.POSITIVE_INFINITY); // Infinityconsole.log(5/0); // Infinityconsole.log(-5/0); // -Infinityconsole.log(Number('hi')); // NaNconsole.log('hi' * 5); // NaNconsole.log(typeof NaN); // numberconsole.log(NaN === NaN); // false
const test = String(5); //---> lưu trên stackconst test1 = new String(5); //---> lưu trên Heapconst test2 = new String(5);console.log(typeof test); // stringconsole.log(typeof test1); // objectconsole.log(test1 === test2); // false
- Khi các giá trị nguyên thủy (primitive) được gán cho các biến hoặc được truyền cho các hàm, nội dung của chúng sẽ được sao chép (passed by value) - nghĩa là khi gán nó cho một biến thì biến sẽ lưu giá trị dữ liệu thực sự chứ không phải là một tham chiếu đến đối tượng được lưu ở vùng nhớ khác. Do đó, các primitive value được xem là tham trị trong JavaScript.
- Các biến chứa primitive value trong closure không được lưu trữ trong bộ nhớ Stack. Nó được lưu trữ trong bộ nhớ Heap.
- JavaScript cho phép chúng ta truy cập trực tiếp vào bộ nhớ Stack và việc thực thi code diễn ra trên Call Stack, do đó, khi so sánh 2 biến (== hoặc ===) thì chính là so sánh 2 giá trị được lưu ở địa chỉ vùng nhớ mà 2 biến này trỏ đến.
let a = 5;let b = a;a = 6;
Objects (reference types)
Các lưu ý
const arr = [1, 2, 3]; // Cấp phát một vùng nhớ 0x1001H trên Heap để lưu [1, 2, 3] và lưu 0x1001H trên Stack
const arr2 = [1, 2, 3]; // Cấp phát một vùng nhớ mới 0x1002H trên Heap để lưu [1, 2, 3] và lưu 0x1002H trên Stack
console.log(arr === arr2); // false - do địa chỉ tham chiếu khác nhau
const arr = [1, 2, 3];
const arr2 = arr;
console.log(arr2); // [1, 2, 3]
arr.push(4);
console.log(arr); // [1, 2, 3, 4]
console.log(arr2); // [1, 2, 3, 4]
Ví dụ mô phỏng quá trình khai báo biến và cấp phát bộ nhớ cho các JavaScript Types
- frame - stack frame - hay còn gọi là execution context.
- Ở trường hợp của person object, khi thực hiện copy một object trong JavaScript, chúng ta có 2 loại copy là shadow copy và deep copy. Trong đó, shadow copy là một dạng copy nông - nghĩa là chỉ copy được một hoặc một số cấp bao ngoài của object, không thể copy hết tất cả các cấp lồng nhau - tức chỉ thực sự copy được những property có giá trị là primitive. Ví dụ shadow copy với toán tử spread (...).
- Nhìn vào cách cấp phát bộ nhớ cho person object, ta có thể hiểu được lý do vì sao khi ta copy bằng toán tử spead, ta chỉ có thể thực sự tạo ra object mới với các property có giá trị là primitive values hoàn toàn tách biệt với object gốc, nhưng với các property có giá trị thuộc type là object vẫn bị tham chiếu.
let person = {
name: 'Kenji',
age: 24,
hobbies: ['movie', 'music']
}
const personCopy = { ...person };
personCopy.name = "KenjiCopy";
personCopy.hobbies.push('game');
console.log(person);
// {
// "name": "Kenji",
// "age": 24,
// "hobbies": [
// "movie",
// "music",
// "game"
// ]
// }
console.log(personCopy);
// {
// "name": "KenjiCopy",
// "age": 24,
// "hobbies": [
// "movie",
// "music",
// "game"
// ]
// }
Tóm tắt
Trong JavaScript có 2 nhóm types: Primitive values và Objects.
- Bạn không thể thay đổi, thêm hoặc xóa các property của primitive vì nó là bất biến. Bạn chỉ có thể tham chiếu đến nó, truy cập và sử dụng nó. Mỗi lần reassign lại biến cho một giá trị primitive mới, một vùng nhớ mới trên Stack sẽ được cấp phát để lưu giá trị mới và thay đổi địa chỉ trỏ đến của biến.
- Biến và giá trị là hai thứ khác nhau, quy tắc áp dụng khác nhau.
- Khi các giá trị nguyên thủy được gán cho các biến hoặc được truyền cho các hàm, nội dung của chúng sẽ được sao chép - passed by value.
- Khi so sánh hai primitive value, nội dung của chúng được so sánh - compared by value.
- Đối với primitive dấu bằng = là toán tử gán, nó là một phép gán thật sự vì nó thực sự tạo ra một giá trị mới được lưu trên Stack.
- Reference types lưu con trỏ (địa chỉ tham chiếu đến dữ liệu thực sự) trên Stack và object values trên Heap.
- Khi các đối tượng được gán cho các biến hoặc được truyền cho các hàm, địa chỉ của chúng (reference) sẽ được sao chép - passed by reference.
- Khi so sánh hai đối tượng bằng các toán tử == hoặc ===, địa chỉ (vị trí được lưu trên Heap) của chúng được so sánh - compare by reference.
- Khi gán giá trị tham chiếu cho một biến, dấu = thực sự thêm một con trỏ vào biến mới để trỏ đến một đối tượng trong bộ nhớ Heap, đây là một loại thao tác "định địa chỉ".
References
- JavaScript’s Memory Model
- Mutability Vs Immutability In JavaScript
- Understanding of JS stack memory and heap memory
- JS Basic Supplements-Stack Memory and Heap Memory
- A quick tour of JavaScript primitives
- JavaScript for impatient programmers (ES2021 edition)
- Explaining Value vs. Reference in Javascript
- How to get a grip on reference vs value in JavaScript
- Call by value, the illusion