07/01/2019, 14:36

Pass-by-reference và pass-by-value

Link gốc bài viết tại đây. Khi học một ngôn ngữ lập trình, một trong những thứ bạn phải nắm được đó là ngôn ngữ đó truyền biến vào hàm bằng cách nào, khi thao tác với biến đó trong hàm thì có ảnh hưởng tới biến nằm ngoài hàm hay không? Điều này là rất cần thiết để tránh những khó hiểu về ...

alt text

Link gốc bài viết tại đây.

Khi học một ngôn ngữ lập trình, một trong những thứ bạn phải nắm được đó là ngôn ngữ đó truyền biến vào hàm bằng cách nào, khi thao tác với biến đó trong hàm thì có ảnh hưởng tới biến nằm ngoài hàm hay không? Điều này là rất cần thiết để tránh những khó hiểu về sau, nhất là những người đang code một ngôn ngữ quen rồi nhảy sang ngôn ngữ khác học.

Ví dụ 2 đoạn code dưới đây giống nhau nhưng kết quả lại trả về khác nhau cho mỗi ngôn ngữ:

// javascript

function test(b) {
  b[0] = 3
}

var a = [1, 2]
test(a)
console.log(a)  // [3, 2]
// PHP

function test($b) {
  $b[0] = 3;
}

$a = [1, 2];
test($a);
var_dump($a)  // [1, 2]

Các ngôn ngữ khác nhau có cách xử lý với các tham số truyền vào khác nhau nhưng có 2 loại truyền tham số đó là truyền theo giá trị (pass-by-value) hay truyền theo tham chiếu (pass-by-reference). Có một số ngôn ngữ có thêm khái niệm pass-by-sharing nhưng khái niệm này thực chất cùng bản chất với 2 khái niệm trên.

Trước khi đọc bài này, cần nắm được cơ bản một số thứ sau:

  • Mutable và immutable là gì?
  • Biến được lưu trữ trên stack như thế nào?

Mình sẽ trình bày cho một số ngôn ngữ là C, C++, js, ruby, java, php.

Phần giải thích dưới đây mình cố gắng bám sát vào việc mô tả bộ nhớ để trình bày nên có lẽ sẽ hơi khó hiểu vì chỉ có bảng mà không có hình ảnh trực quan.

Nếu bạn muốn bỏ qua phần mô tả bộ nhớ và xem bằng hình ảnh thì nhảy xuống phần Mô tả bằng hình ảnh ở cuối bài.

  • CPU xử lý dữ liệu thông qua địa chỉ bộ nhớ nên thứ được truyền vào hàm luôn luôn là địa chỉ bộ nhớ chứ không phải là giá trị.
  • Pass-by-valuepass-by-reference không có định nghĩa cụ thể nào và có thể hiểu khác nhau với từng ngôn ngữ. Nhưng đều có chung một nguyên lý là:
  • Pass-by-value được hiểu là khi bạn thay đổi biến trong hàm thì ngoài hàm sẽ không bị ảnh hưởng. Nó giống như bạn copy giá trị của biến vào biến khác rồi truyền vào hàm.
  • Pass-by-reference là khi bạn thay đổi biến trong hàm cũng làm ngoài hàm bị ảnh hưởng. Nó giống như bạn truyền đúng địa chỉ của biến đó vào hàm.
  • Khi chương trình thực thi, dữ liệu trên RAM có thể được lưu trữ trên stack hoặc heap nhưng việc tham chiếu bằng địa chỉ giữa các biến là như nhau nên để cho đơn giản mình sẽ giả sử chúng chỉ được lưu trữ trên stack.
  • Trong tất cả ngôn ngữ, khi khai báo một hàm thì tham số của hàm có thể khác hoặc trùng với tên biến được truyền vào hàm.

Ví dụ 2 đoạn code dưới đây là hoàn toàn như nhau:

var a = 1
pass(a)

function pass(a) { // tham số cũng có tên là a, trùng với tên biến sẽ được truyền vào.
  // dùng a ở đây.
}
var a = 1
pass(a)

function pass(b) {
  // dùng b ở đây
}

C luôn truyền theo giá trị (pass-by-value)

C++ có thể truyền theo giá trị (pass-by-value) hoặc truyền theo tham chiếu (pass-by-reference).

C và C++ pass-by-value như thế nào?

// Đoạn code này giống nhau trong C và C++

#include <iostream>
#include <string>

void test(int b) {
  printf("Địa chỉ của b trước khi gán: %d
", &b);
  b = 2;
  printf("Địa chỉ của b sau khi gán: %d
", &b);
}

int main()
{
    int a = 1;
    printf("Địa chỉ của a trước khi truyền vào hàm: %d
", &a);

    test(a);

    printf("Địa chỉ của a sau khi gọi hàm: %d
", &a);
    printf("Giá trị của a sau khi gọi hàm: %d
", a);
}

Biến a trên trong bảng symbol table sẽ như sau:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 1

Khi gọi hàm test(a) từ hàm main thì biến a sẽ được copy thành một biến mới và địa chỉ của biến mới này sẽ được truyền vào hàm test:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 1
b ssss2 1

CC++ có kiểu intmutable nên câu lệnh b = 2 sẽ tạo:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 1
b ssss2 2

Như bạn thấy biến a vẫn trỏ tới ssss1 và vẫn có giá trị là 1 không hề bị ảnh hưởng.

Đoạn code trên sẽ in ra như sau:

Địa chỉ của a trước khi truyền vào hàm: -1730233380
Địa chỉ của b trước khi gán: -1730233412
Địa chỉ của b sau khi gán: -1730233412
Địa chỉ của a sau khi gọi hàm: -1730233380
Giá trị của a sau khi gọi hàm: 1

Nhưng nếu ai đã code C sẽ biết C có thể thay đổi giá trị của biến ngoài hàm bằng cách truyền con trỏ (pointer), điều này vẫn đúng khi nói Cpass-by-value vì:

Con trỏ (pointer) có phải là pass-by-value không?

// Đoạn code này giống nhau trong C và C++

#include <iostream>
#include <string>

void test(int* b) {
  printf("Địa chỉ của b trước khi gán: %d
", &b);
  printf("Giá trị của b trước khi gán: %d
", b);
  printf("Giá trị của a ở trong hàm trước khi gán: %d

", *b);

  *b = 2;

  printf("Địa chỉ của b sau khi gán: %d
", &b);
  printf("Giá trị của b sau khi gán: %d
", b);
  printf("Giá trị của a ở trong hàm sau khi gán: %d

", *b);
}

int main()
{
    int a = 1;
    printf("Địa chỉ của a trước khi truyền vào hàm: %d

", &a);

    test(&a); // vì b trong test() đã là kiểu con trỏ nên phải truyền địa chỉ của a vào.

    printf("Địa chỉ của a sau khi gọi hàm: %d
", &a);
    printf("Giá trị của a sau khi gọi hàm: %d
", a);
}

Khi gọi test(&a) thì:

  • &a sẽ trả về địa chỉ của a.
  • Địa chỉ này sẽ được copy vào một biến khác và truyền địa chỉ của biến khác này vào hàm test (trong hàm test thì địa chỉ của biến khác này chính là b).
Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 1
b ssss2 ssss1

Thứ được truyền vào hàm test sẽ là ssss2.

Trong hàm test, bạn không thể gán như thông thường b = 2 được vì b giờ đã là kiểu con trỏ, nếu bạn muốn gán b cho một giá trị nào đó thì phải *b = 2.

*b = 2 sẽ gán giá trị vào ô nhớ mà b đang trỏ tới (chứ không phải giá trị ô nhớ của b).

Sau khi gán câu lệnh trên xong thì symbol table sẽ như sau:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 2
b ssss2 ssss1

Và output của đoạn code trên là:

Địa chỉ của a trước khi truyền vào hàm: -1375358500

Địa chỉ của b trước khi gán: -1375358536
Giá trị của b trước khi gán: -1375358500
Giá trị của a ở trong hàm trước khi gán: 1

Địa chỉ của b sau khi gán: -1375358536
Giá trị của b sau khi gán: -1375358500
Giá trị của a ở trong hàm sau khi gán: 2

Địa chỉ của a sau khi gọi hàm: -1375358500
Giá trị của a sau khi gọi hàm: 2

Như bạn thấy đấy, khi truyền con trỏ vào hàm thì vẫn copy địa chỉ sang biến khác rồi truyền vào hàm nên nó vẫn là pass-by-value (một số người gọi truyền theo pointerpass-by-address).

C++ có thêm pass-by-reference

Trong C nếu muốn thay đổi giá trị của biến ngoài hàm từ trong hàm thì phải truyền con trỏ như giải thích ở trên. Nhược điểm của phương pháp này là:

  • Phải tạo thêm một vùng không gian nhớ cho pointer trong stack.
  • Cú pháp xấu vì luôn phải gắn asterisk (dấu *) vào trước tên biến.

VD:

#include <iostream>
#include <string>

void test(int* x, int* y) {
  *x = *x + 5;
  *y = *y + 5;
}

int main() {
  int x = 1;
  int y = 2;
  test(&x, &y);
  printf("x: %d
", x);  // 6
  printf("y: %d
", y);  // 7
}

Trong C++ có giải pháp thay thế là pass-by-reference giúp ít nhất là tránh các nhược điểm trên:

#include <iostream>
#include <string>

void test(int &x, int &y) {
  x = x + 5;
  y = y + 5;
}

int main() {
  int x = 1;
  int y = 2;
  test(x, y);
  printf("x: %d
", x);  // 6
  printf("y: %d
", y);  // 7
}

Với đoạn code ở phần trên dùng pass-by-reference:

#include <iostream>
#include <string>

void test(int &b) {
  printf("Địa chỉ của b trước khi gán: %d
", &b);
  printf("Giá trị của b trước khi gán: %d
", b);

  b = 2;

  printf("Địa chỉ của b sau khi gán: %d
", &b);
  printf("Giá trị của b sau khi gán: %d

", b);
}

int main()
{
    int a = 1;
    printf("Địa chỉ của a trước khi truyền vào hàm: %d

", &a);

    test(a);

    printf("Địa chỉ của a sau khi gọi hàm: %d
", &a);
    printf("Giá trị của a sau khi gọi hàm: %d
", a);
}

Lúc này symbol table trước khi thực thi sẽ như sau:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 1
b ssss1 1

Sau khi thực thi sẽ như sau:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 2
b ssss1 2

Vì a và b cùng trỏ đến một địa chỉ stack nên gán b ở trong hàm cũng làm a ở ngoài hàm bị thay đổi.

Trong bảng symbol table trên có thêm biến b nhưng trong stack không phải cấp phát thêm một vùng bộ nhớ cho b vì sử dụng chung ssss1 với a.

function changeStuff(a, b, c) {
  a = a * 10;
  b = {item: "changed"};
  c.item = "changed";
}

num = 10;
obj1 = {item: "unchanged"};
obj2 = {item: "unchanged"};

changeStuff(num, obj1, obj2);

console.log(num)       // 10
console.log(obj1)      // {item: "unchanged"}
console.log(obj2)      // {item: "changed"}

Một số thứ cần biết trong js

  • Tất cả các kiểu primitive trong js đều là immutable.
  • Khi so sánh 2 biến có kiểu primitive với nhau thì chúng sẽ so sánh giá trị:
var a = 3

function test(b) {
    console.log(a === b)  // true
    var b = 3
    console.log(a === b)  // true
}

test(a)
  • Nhưng khi so sánh 2 kiểu object (không phải các kiểu primitive) thì chúng sẽ so sánh địa chỉ của 2 object đó với nhau:
var a = {item: [1, 2]}

function test(b) {
    console.log(a === b)   // true
    var b = {item: [1, 2]}
    console.log(a === b)   // false
}

test(a)

Javascript pass-by-value hay pass-by-reference

Nhiều người nói js chỉ pass-by-value, cũng nhiều người nói js vừa có pass-by-value, vừa có pass-by-reference. Nhưng:

Thứ bạn quan tâm không phải là nó pass theo cái gì mà phép gán trong JS hoạt động như thế nào.

Javascript truyền cùng một địa chỉ với biến vào hàm chứ không copy sang biến khác giống C.

Ở ví dụ trên, trước khi b bị gán lại thì object aobject b bằng nhau nên chúng sẽ có chung một địa chỉ trên stack.

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 {item: [1, 2]}
b ssss1 {item: [1, 2]}
ssss1.item yyyy1 [1, 2]

Sau khi bị gán lại bằng câu lệnh b = {item: [1, 2]} thì thành:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 {item: [1, 2]}
ssss1.item yyyy1 [1, 2]
b ssss2 {item: [1, 2]}
ssss2.item yyyy2 [1, 2]

Nhưng vì sao a và b cùng trỏ tới một địa chỉ nhưng khi thay đổi b thì a lại không thay đổi?

Xét ví dụ:

var a = 3

function test(b) {
  // b và a ở đây đều có cùng một địa chỉ bộ nhớ.
  b = 4
}

test(a)

console.log(a)   // 3

Bởi vì trong js, các kiểu primitiveimmutable (Đọc thêm về immutable tại đây).

Trước khi phép gán b = 4 được gọi:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 3
b ssss1 3

Sau khi phép gán b = 4 được gọi:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 3
b ssss2 4

Như bạn thấy, b giờ đã trỏ tới ssss2 nên không ảnh hưởng gì tới a.

Giải thích tương tự với đoạn code sau:

function test(b) {
  b = {item: "changed"}
}

var a = {item: "unchanged"}
test(a)
console.log(a)      // {item: "unchanged"}

Nhưng khi bạn gán thuộc tính của object thì chuyện gì sẽ xảy ra:

function test(b) {
  b.item = "changed"
}

var a = {item: "unchanged"}
test(a)
console.log(a)      // {item: "changed"}

Để giải thích cho VD trên cần phân tích {item: "unchaged"} trong stack:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 {item: "unchanged"}
b ssss1 {item: "unchanged"}
ssss1.item yyyy1 "unchanged"

Trong bảng trên thì ssss1.item chính là a.item và cũng chính là b.item.

Khi gán b.item = "changed" nghĩa là mình đã gán giá trị mà item trỏ tới (hiện tại đang là yyyy1) thành "changed", lúc đó stack sẽ thành:

Tên biến Địa chỉ của biến trên stack Giá trị của biến trong stack
a ssss1 {item: "changed"}
b ssss1 {item: "changed"}
yyyy1 "unchanged"
ssss1.item yyyy2 "changed"

Lưu ý là phép gán sẽ không ghi đè giá trị tại yyyy1 mà tạo một yyyy2 mới vì string trong js (cụ thể ở đây là ssss1.item) là immutable.

Như bạn thấy a và b cùng trỏ tới ssss1, ssss1 trỏ tới thằng item tại yyyy2, nên khi thay đổi item ngay trong a hoặc b thì thằng còn lại cũng sẽ bị thay đổi.

Giờ thì bạn hiểu đoạn code ở đầu mục javascript rồi đấy :))

Viết lại đoạn code js phía trên thành ruby:

def changeStuff(a, b, c)
  a = a * 10
  b = {item: "changed"}
  c[:item] = "changed"
end

num = 10
obj1 = {item: "unchanged"}
obj2 = {item: "unchanged"}

changeStuff(num, obj1, obj2)

p num   # 10
p obj1  # {item: "unchanged"}
p obj2  # {item: "changed"}

Ruby giải thích hoàn toàn tương tự như js, thậm chí còn dễ kiểm chứng hơn vì biết được object_id của từng object:

a = {item: [1, 2]}
p "Before test:"
p a.object_id
p a[:item].object_id

def test b
  p "Check b same as a:"
  p b.object_id # == a.object_id
  p b[:item].object_id # == a[:item].object_id

  c = b

  b = {item: [1, 2]}
  p "After assign 1:"
  p b.object_id # != a.object_id
  p b[:item].object_id # != a["item"].object_id

  c[:item] = [1, 2, 3]
  p "After assign 2:"
  p c.object_id # == a.object_id
  p c[:item].object_id # Lúc này thì a[:item].object_id cũng thay đổi theo
end

test a
p "After test:", a[:item].object_id

Kết quả in ra của đoạn code trên là:

"Before test:"
47385869293940
47385869293960

"Check b same as a:"
47385869293940
47385869293960

"After assign 1:"
47385869289160
47385869289180

"After assign 2:"
47385869293940
47385869278760

"After test:"
47385869278760

PHP giống C++, cũng copy ra biến mới rồi mới truyền vào hàm (kể cả array).

Nhưng từ PHP5 trở đi thì kiểu object truyền vào hàm sẽ giống như javascript là truyền tham chiếu vào.

class Test {
    public $item = "unchanged";
}

function changeStuff($a, $b, $c, $d) {
  $a = $a * 10;
  $b = new Test;
  $c->item = "changed";
  $d[0] = 3;
}

$num = 10;
$obj1 = new Test;
$obj2 = new Test;
$array = [1, 2];

changeStuff($num, $obj1, $obj2, $array);

var_dump($num);   // 10
var_dump($obj1);  // {"item" => "unchanged"}
var_dump($obj2);  // {"item" => "changed"}
var_dump($array);  // [1, 2]

Giải thích cho kiểu object tương tự như javascript, còn giải thích cho các kiểu còn lại tương tự như CC++.

import java.util.Arrays;

public class HelloWorld
{
  public static void main(String[] args)
  {
    int num = 10;
    Test obj1 = new Test();
    Test obj2 = new Test();
    int[] array = {1, 2}; // [1, 2]

    changeStuff(num, obj1, obj2, array);
    System.out.println(num);
    System.out.println(obj1.item);
    System.out.println(obj2.item);
    System.out.println(Arrays.toString(array));
  }

  public static void changeStuff(int a, Test b, Test c, int[] d)
  {
    a = a * 10;
    b = new Test();
    c.item = "changed";
    d[0] = 3;
  }
}

public class Test
{
  public String item = "unchanged";
}

Kết quả in ra là:

10         // num
unchanged  // obj1.item
changed    // obj2.item
[3, 2]     // array

Java giải thích hoàn toàn giống javascript.

Lưu ý là trong java ta có thể gán bằng các phương thức mutable hoặc immutable nên symbol table có thể khác nhau phụ thuộc vào phép gán.

Mình chỉ minh họa bằng hình ảnh đại diện cho đoạn javascript sau:

function changeStuff(a, b, c) {
  a = a * 10;
  b = {item: "changed"};
  c.item = "changed";
}

num = 10;
obj1 = {item: "unchanged"};
obj2 = {item: "unchanged"};

changeStuff(num, obj1, obj2);

console.log(num)       // 10
console.log(obj1)      // {item: "unchanged"}
console.log(obj2)      // {item: "changed"}

Trước khi phép gán xảy ra:
alt text

Hình ảnh mô tả cho đoạn code javascript phía trên tương tự với phần lớn các ngôn ngữ khác nhưng khác với C/C++. Như đã nói ở phần trên, trong C/C++ thì biến a, b, c sẽ được copy nên không trỏ vào cùng một ô nhớ với các biến khai báo giống trong hình ảnh.

Sau khi phép gán xảy ra:

alt text

Link tham khảo:
https://stackoverflow.com/questions/2229498/passing-by-reference-in-c
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness

0