Phân biệt "By value" vs "By reference" trong Javascript

Ngôn ngữ lập trình đầu tiên mà tôi học đó là C++, và trong khi học ngôn ngữ này, tôi được học khái niệm về pass-by-value và pass-by-reference; C++ mặc định sử dụng pass-by-value cho việc truyền argument vào function. Bạn cũng có thể chỉ định việc sử dụng pass-by-reference bằng cách đặt một dấu & giữa kiểu dữ liệu và tên param trong danh sách params đầu vào của function. Ngược lại, sau này, khi làm việc với Javascript, tôi đã phát hiện ra rằng ngôn ngữ này không cho bạn lựa chọn việc đó: Tự bản thân Javascript đã mặc định kiểu truyền dữ liệu của mình và cách các variable được gán với bộ nhớ. Trong quá trình làm việc với các project của mình, tôi đã gặp một số vấn đề liên quan tới khái niệm này, vì vậy tôi đã tìm hiểu và viết một bài dưới đây về cách mà Javascript thực hiện mọi thứ phía sau khi ta truyền argument vào function.

Ôn lại kiến thức

Đầu tiên, hãy cũng nhau nhớ lại một số khái niệm cơ bản mà ta đã được học từ hồi Đại học.

Đầu tiên, Javascript là một ngôn ngữ hướng đối tượng: điều này có nghĩa là hầu hết mọi thứ trong Javascript là Object. Ví dụ: function là Objects. Chỉ có 1 vài thứ trong Javascript không phải Object - chúng được gọi là các kiểu dữ liệu nguyên thủy (Primitive Data Types): string, number, boolean, null và undefined. Các Types này cũng có một đặc tính đó là immutable: chúng chỉ có thể được tạo ra mà không thể bị modify.

Tiếp theo, cùng nhớ lại các khái niệm về pass-by-value và pass-by-reference.

Variable giữ data của nó trong bộ nhớ (memory), nhưng bản thân variable nó chỉ là một con trỏ trỏ tới vùng nhớ trên memory. Bằng cách khởi tạo 1 variable, ta đã nói với chương trình "hãy chọn lấy một vùng trong bộ nhớ máy tính, lưu địa chỉ lại để sau này có thể gán giá trị hay lôi giá trị ra".

Trong Pass-By-Reference , reference là để ám chỉ tới vùng bộ nhớ; trong khi với Pass-By-Value, value là để nói tới giá trị thực sự được lưu trong vùng bộ nhớ đó.

Để dễ hiểu, ta có thể hình dùng 2 khái niệm này qua ví dụ dưới.

By Value có nghĩa ta tạo một BẢN SAO của cái gốc: Hình dung một cặp sinh đôi: 2 đứa bé sinh đôi giống hệt nhau, nhưng một đứa sẽ không bị mất chân nếu ta chặt cái chân của đứa kia (:v) By Reference có nghĩa là ta tạo một alias tới cái gốc: Khi tên bạn là Johnson và ở nhà bạn mẹ gọi bạn với cái tên Tom, điều đó không có nghĩa là một đứa clone nào nó giống bạn tự dung được sinh ra : bạn vẫn là bạn, nhưng bạn có thể được gọi bởi 2 cái tên khác nhau.

Như vậy, khi nói tới khái niệm pass-by-value tức là ta muốn nói rằng "việc truyền một argument vào 1 function thực chất là copy giá trị (value) của nó và chuyển cho parameter bên trong function". Điều này có nghĩa parameter bên trong function hoàn toàn không phải là variable mà ta truyền vào qua argument của function đó - 2 thứ này có chung giá trị, nhưng các giá trị đó được tạo trên các vùng bộ nhớ khác nhau, cho nên khi ta thay đổi giá trị của 1 trong các parameter đó trong function thì cũng không ảnh hưởng tới variable bên ngoài function. Không có mối liên kết nào giữa variable bên ngoài function và parameter bên trong function vì tất cả những gì ta đã làm là truyền giá trị.

// Mã giả ví dụ cho pass-by-value

function funny(number) {
  number = 7;
}

num = 5;
funny(num);
console.log(num);      // => `num` vẫn có giá trị là 5

Trong ví dụ trên: variable num vẫn có giá trị là 5 cho dù function funny đã đổi nó thành 7. Đó là bởi vì vùng bộ nhớ mà num trỏ tới không được truyền vào cho function, thứ được truyền vào là giá trị 5. Parameter number được khởi tạo bên trong function trỏ tới một vùng bộ nhớ hoàn toàn mới và giữ giá trị 5, mà sau đó được thay đổi lại bằng 7.

Ngược lại, với Pass-by-reference, thứ ta truyền vào function không phải là giá trị của biến mà là địa chỉ của vùng nhớ mà biến đó trỏ tới. Trong ví dụ tiếp theo, địa chỉ vùng nhớ của num được truyền vào function do đó number lúc này thực sự là 1 reference tới cùng một vùng nhớ với num ! Rõ ràng lúc này mọi thứ ta làm với number sẽ ảnh hưởng ngược lại tới num.

// Mã giả ví dụ cho pass-by-reference

function funny(number) {
  number = 7;
}

num = 5;
funny(num);
console.log(num);      // => 7

Javascript Pass-By

Javascript tuân theo 1 quy tắc trong việc truyền argument vào function. Đó là Javascript luôn luôn sử dụng pass-by-value. Thứ được truyền vào argument list luôn luôn là value của biến bên ngoài.

Mặc dù vậy, khi mà kiểu của argument được truyền vào không phải là một primitive type, lúc này value được truyền vào lúc này thực chất lại là địa chỉ vùng nhớ (reference) ! Vì vậy, với 5 kiểu dữ liệu primitive (string, number, boolean, null và undefined) , chúng được pass by value như ta mong đọi. Nhưng với bất kì kiểu dữ liệu phức tạp nào khác (object hay array ...) , chúng được pass-by-value với value chính là reference, vì vậy từ bên ngoài nhìn vào, chúng có vẻ như là được pass-by-reference !

(Với bất kì bài phỏng vấn nào, bạn nên nhớ lại sự khác biệt căn bản trên và không nên nhầm lẫn nó).

Dưới đây là ví dụ về cách mà Javascript thực hiện với các argument kiểu primitive:

function funny(number) {
  number+= 7;
}
let num = 5;
funny(num);
console.log(num);       // 5

Và đây là với các argument có kiểu non-primitive:

function funny(arr, obj) {
  arr.push(4);
  obj.food = 'apple;
}

let myArr = [1,2,3];
let myObj = { name: 'Tony', food: 'pizza' };

funny(myArr, myObj);

console.log(myArr);        //  => [1,2,3,4]
console.log(myObj);       //  => { name: 'Tony', food: 'apple' }


Trong ví dụ trên, ta có thể thấy object và array trông như là được pass-by-reference , bởi vì myArrmyObj được thay đổi bên trong function. Đó là do địa chỉ bộ nhớ của myArrmyObj được truyền vào function thay vì giá trị thực sự của các array hay object này.

Pass-by-reference rất tuyệt bởi vì khi ta truyền vào nhiều object hay array vào một function, ta không cần phải trả về tương tự từng đó thứ. Tuy nhiên , nếu như bạn đang luyện tập code theo chuẩn no-side effect trong functional programming , hãy cẩn thận bởi vì function của bạn - bằng cách thay đổi giá trị value bên ngoài nó - sẽ gây ra thay đổi state của chương trình. Tuy nhiên, điều này nằm ngoài phạm vi bài viết hiện tại, vì vậy hãy bỏ qua nó.

Tricks of memory:

Cần lưu ý lại rằng, không phải ta muốn làm gì với params kiểu object hay array cũng được và vẫn có sự thay đổi tương ứng đối với variable gốc được truyền vào. Nguyên do ở đây là các vùng bộ nhớ. Nếu như bạn chỉ cập nhật hay thay đổi các element nhỏ lẻ của biến (item của array, property của object ...) vậy thì reference tới variable vẫn giữ nguyên, hay nói cách khác là mối liên hệ giữa argument và variable bên ngoài vẫn còn khi cả 2 vẫn trỏ tới cùng 1 vùng bộ nhớ.

Tuy nhiên, trong javascript, ngay khi ta assign (gán) một giá trị mới cho 1 biến, một vùng bộ nhớ mới sẽ được gọi tới , gán cho biến đó với giá trị được gán. Vì vậy khi ta assign nguyên một array cho một array parameter của function, ta sẽ khiến parameter đó trỏ tới một vùng bộ nhớ hoàn toàn mới, trong khi array gốc bên ngoài function vẫn trỏ tới vùng bộ nhớ gốc. Điều tương tự cũng xảy ra khi ta assign một object hoàn toàn mới cho một parameter kiểu object của function .Lúc này, mối liên kết (địa chỉ bộ nhớ được chia sử lúc trước) bị gãy , đồng thời việc thay đổi bên trong function không còn ảnh hưởng tới variable bên ngoài function nữa.

Ở ví dụ cuối cùng phía trên kia , reference được giữ lại bởi vì function đã trực tiếp thay đổi giá trị bên trong array và object bằng các method push của array để thêm item cho mảng, cũng như trực tiếp update giá trị cho 1 property cho object.

Thay vào đó, ở ví dụ dưới đây, tôi sẽ demo cho bạn thấy cách để bẻ gãy mối liên kết giữa variable bên ngoài với argument bên trong fuction bằng cách assign hẳn một giá trị mới cho params:

function funny(arr, obj) {
  arr = [4,5,6];
  obj = { age: 15 };
  
  console.log(arr);     // => [4,5,6]
  console.log(obj);    // => { age: 15 }
}

let myArr = [1,2,3];
let myObj = { name: 'Tony', food: 'pizza' };

funny(myArr, myObj);

console.log(myArr);       // [1,2,3]
console.log(myObj);       // { name: 'Tony', food: 'pizza' }

Trong ví dụ trên, khi [4,5,6] được assign cho arr, Javascript sẽ tạo một vùng bộ nhớ mới cho [4,5,6] và assign địa chỉ vùng nhớ đó cho arr. 2 địa chỉ của arrmyArr lúc này đã khác nhau nên arr không còn thay đổi ngược lại cho myArr nữa. Điều tương tự xả ra với objmyObj

Vấn đề trong bài toán của tôi.

Lý do mà tôi viết bài này vởi vì trước đó tôi đã gặp phải một vấn đề trong project mà tôi đang làm việc. Khi mà tôi mong đợi rằng array mà tôi truyền vào 1 function sẽ được update dựa theo những gì tôi làm bên trong function, tôi nhanh chóng nhận ra nó không thực hiện đúng như tôi muốn.

Hãy cùng tìm hiểu xem tôi đã sai ở đâu.

Tôi có một mảng các id của user và một mảng tương đương chứa tọa độ GPS của các user đó. Điều tôi muốn là truyền cả 2 array này vào 1 function, cùng với khoảng cách xa nhất và 1 cặp tọa độ GPS, sau đó tính ra khoảng cách giữa mỗi cặp tọa độ trong mảng. Function sẽ lọc lấy các user nào đứng xa hơn khoảng cách max distance kia, và tạo một mảng các distances tương ứng với các user còn lại trong mảng. Mảng các distance này được trả lại bởi function và assign cho mảng bên ngoài function để thể hiện khoảng cách tới mỗi user từ cặp tọa độ độc lập.

Điều quan trọng với function này đó là mảng user bên ngoài truyền vào có thể được filter bên trong function. Mặc dù vậy, khi tôi sử dụng method filter của Javascript - Array.prototype.filter - nó lại trả về cho tôi một array hoàn toàn mới. Vì vậy, bên trong function, khi tôi assigin kết quả của hàm filter cho users array, mảng này đã được assign một vùng bộ nhớ hoàn toàn mới, đồng nghĩa với việc users array bên ngoài function không còn kết nối tới thứ bên trong function nữa.

Code của tôi hiện tại như sau:

userArray;       // mảng id của users
coordsArray;     // mảng tọa độ của từng user trong userArrray
myCoords;        // has pair of coordinates for this user in lat, lon properties
maxDist;         // mang dữ liệu kiểu number

let distArray = convertGpsToMiles(userArray, coordsArray, myCoords, maxDist);

console.log(userArray);       // vẫn giữ giá trị của userArray 

...

function convertGpsToMiles(users, coords, myLocation, maxD) {
  let miles;
  let distances = [];
  users = users.filter((el, index) => {
    miles = convertCoordsToDistance(myLocation, coords[i].lat, coords[i].lon);
    if (miles <= maxD) {
      distances.push(miles);
      return true;
    } else {
      return false;
    }
  });
  return distances;
}

userArray bên ngoài và bên trong function bị chia cắt khi tôi assign mảng đã filter cho users


Giải pháp

Trước khi tôi thực sự nghĩ tới vấn đề về bộ nhớ của Javascript, tôi đã thử vài cách khác, và đương nhiên là không có cách nào thực sự chạy ổn cả.

Đối với Javascript thuần, tôi có thể sử dụng vòng lặp for-loop thay cho Array.prototype.filter. Trong vòng lặp for, nếu miles ít hơn hoặc bằng maxD, thì tôi sẽ push miles vào mảng distances và sử dụng Array.prototype.splice để loại bỏ đi những user không mong muốn. Bằng cách này tôi đã trực tiếp thay đổi array của users thay vì gán một array mới cho nó. Tuy nhiên, vì hàm splice thay đổi độ dài của array, thế nên dẫn đến việc tôi lại phải đảm bảo rằng biến couter của vòng lặp for được đồng bộ bằng với độ dài của array, và điều này cũng lại dẫn tới tôi cũng cần phải cắt bỏ các element ở vị trí tương đương phía trên mảng coords để đảm bảo mảng coords tương đương với mảng users

function convertGpsToMiles(users, coords, myLocation, maxD) {
  let miles;
  let distances = [];
  for (let i=0; i < users.length; i++) {
    miles = convertCoordsToDistance(myLocation, coords[i].lat, coords[i].lon);
    if (miles <= maxD) {
      distances.push(miles);
    } else {
      users.splice(i, 1);
      coords.splice(i, 1);
      i--;
    }
  }
  return distances;
}

Hàm này sẽ chạy, và sẽ chạy nhanh hơn là sử dụng Array.prototype.filter! Vì thế khi ứng dụng của tôi trở nên nổi tiếng và có nhiều user sử dụng hơn, tôi có thể sẽ muốn optimize lại hàm này bằng việc sử dụng giải pháp này.

Tuy nhiên, vì hiện tại tôi đã code project này bằng Angular, tồn tại một giải pháp khác cho tôi đó là sử dụng angular.copy() với cú pháp Angular.copy(src, dest)

Method này thực hiện một quy tình deep-copy từ array nguồn tới array đích, có nghĩa rằng nó copy từng element từ bên này sang bên kia, đồng nghĩa với địa chỉ bộ nhớ không thay đổi. Điều này khác với việc trỏ array đích tới vị trí ô nhớ của array nguồn, hay copy luôn toàn bộ array nguồn và trỏ array đích tới địa chỉ của ô nhớ vừa copy (như những gì hàm Array.prototype.slice sẽ làm khi không có argument nào)

Angular doc mô tả chính xác những gì nó làm : nó clear sạch sẽ array đích và copy từng element từ array nguồn qua:

* @ngdoc function
 * @name angular.copy
 * @module ng
 * @kind function
 *
 * @description
 * Creates a deep copy of `source`, which should be an object or an array.
 *
 * * If no destination is supplied, a copy of the object or array is created.
 * * If a destination is provided, all of its elements (for arrays) or properties (for objects)
 *   are deleted and then all elements/properties from the source are copied to it.

Lúc này, đối với vấn đề bên trên, tôi sử dụng angular.copy(), truyền vào array đã filter như là array đích và parameter users như là arrray nguồn.

function convertGpsToMiles(users, coords, myLocation, maxD) {
  let miles;
  let distances = [];
  angular.copy(users.filter((el, i) => {
    miles = convertCoordsToDistance(myLocation, coords[i].lat, coords[i].lon);
    if (miles <= maxD) {
      distances.push(miles);
      return true;
    } else {
      return false;
    }
  }), users);
  return distances;
}

IT WORKS !

Kết luận

Tổng kết lại, Javascript truyền các primitives type bằng value và có thể coi như truyền các kiểu dữ liệu khác (object, arrray ...) bằng reference. Nhưng bạn cần cẩn thận với việc re-assign một object hay array bên trong một function bởi việc đó đồng nghĩa với tạo ra 1 đối tượng hoàn toàn mới, không còn liên quan gì tới argument gốc nữa. Hiểu đơn giản, khi ta dùng operator = - với một array hay object hoàn toàn mới - ta đã vừa gán cả một vùng bộ nhớ mới cho variable. Thay vào đó, nếu ta cần giữ lại reference của variable trong function, ta có thể cần phải trực tiếp thay đổi từng property hay element của variable, với các hàm như splice(), push(), ... thay vì các hàm sẽ trả về object mới (như là filter(), map(), slice() ...)

Nguồn dịch:

https://medium.com/@TK_CodeBear/javascript-arrays-pass-by-value-and-thinking-about-memory-fffb7b0bf43 https://hackernoon.com/grasp-by-value-and-by-reference-in-javascript-7ed75efa1293