Tại sao hôm nay có bài này?

Mình chẳng bao giờ code C, C++ nên chẳng bao giờ nhớ được các khái niệm về con trỏ. Nên mình không thích con trỏ tí nào cả. Nhưng khổ cái này trong một số trường hợp mình lại vẫn muốn hiểu nó, mà do không làm việc với C, C++ (và sau này có Golang) nên mình lại rất hay “quên”. Nên tổng hợp lại để khi nào quên thì vô đọc lại.

1. Hàm, đối số, tham số

Ví dụ ta có 1 hàm

int foo(int a, int b){
  //
}

Ta có 2 tham số (parameters) là a và b. Giả sử trong hàm main, gọi hàm foo

int main() {
  foo(1, 2);
  return 0;
}

Ta có 2 đối số (arguments) là 1 và 2. Ta nói giá trị 12 được lưu tạm trong 2 tham số là ab.

Trong C++ hỗ trợ nhiều kiểu truyền đối số khác nhau, tương ứng khai báo tham số trong hàm sẽ khác nhau:

  • Truyền đối số là giá trị như phía trên.
  • Truyền đố số là tham chiếu.
  • Truyền đối số là địa chỉ.

1.1 Truyền đối số là giá trị

Đối số trong hàm con foo(int a, int b)ab có vai trò là biến cục bộ, chỉ hoạt động bên trong hàm foo(), khi hàm này kết thúc các tham số này sẽ bị hủy và giá trị truyền vào sẽ không tồn tại.

Mọi thay đổi của tham số bên trong hàm, không có tác động gì đến giá trị gốc được truyền vào ngoài hàm. Ví dụ

#include <iostream>
using namespace std;

void foo(int p) {
  p = p + 1;
  cout << p << endl;
}

int main() {
  int a = 10;
  foo(a);
  cout << a << endl;
  return 0;
}

Ta được kết quả, như ta thấy giá trị của đối số a không thay đổi sau khi gọi hàm foo()

11
10

Press ENTER or type command to continue

Điều này có nghĩa là cái mà tham số p tiếp nhận chỉ là bản sao giá trị của đối số a. Thử in địa chỉ của đối số và tham số:

#include <iostream>
using namespace std;

voin print_addr_of_parameter(int p) {
  cout << "addr of parameter is: " << &p << endl;
}

int main() {
  int a;
  print_addr_of_parameter(a);
  cout << "addr of argument is: " << &a << endl;
}

Ta được kết quả:

addr of parameter is: 0x7ffee28615bc
addr of argument is: 0x7ffee28615ec

Press ENTER or type command to continue

Như ta thấy, đối số và tham số có địa chỉ khác nhau, dù có cùng giá trị.

1.2 Truyền đối số cho hàm là tham chiếu

Nhược điểm của truyền đối số cho hàm là giá trị là

  • Cách duy nhất để có được đầu ra là phải trả về giá trị thông qua từ khóa return.
  • Hàm chỉ trả về một giá trị duy nhất cho mỗi lần gọi hàm.

Trong khi các ngôn ngữ bậc cao như python thì việc trả về nhiều giá trị rất đơn giản. Thực hiện truyền đối số cho hàm là tham chiếu giúp ta khắc phục nhược điểm trên.

Biến tham chiếu (reference variable) được xem như một tên khác của một biến nào đó có cùng kiểu dữ liệu. Sau khi được khai báo và khởi tạo, nó dùng chung vùng nhớ với biến mà nó tham chiếu tới.

Đặc điểm của biến tham chiếu là khi một trong hai biến bị hủy (biến tham chiếu hoặc biến gốc) do ra khỏi khối lệnh được khai báo, vùng nhớ của 2 biến này vẫn chưa bị hủy nếu còn ít nhất 1 biến quản lý.

int main() {
  int n = 0;
  int &ref = n;
    
  {
    int temp = 5;
    ref = temp;
  }
    
  cout << ref << endl;
  cout << n << endl;
  return 0;
}

Như ở ví dụ trên, biến temp sau khi ra khỏi khối lệnh đã bị hủy, tuy nhiên vẫn tồn tại biến ref kiểm soát vùng nhớ của biến temp nên kết quả sẽ là:

5
5

Press ENTER or type command to continue

Bây giờ ta có thể định nghĩa một hàm không có gía trị trả về (không có return) tương tự như hàm có kết quả trả về.

int add_one(int value) {
  return value + 1;
}

int main() {
  int n = 9;
  n = add_one(n);
  
  cout << n << endl;
  return 0;

}
void add_one(int &value) {
  value++;
}

int main() {
  int n = 9;
  add_one(n);
  
  cout << n << endl;
  return 0;

}

Đều có kết quả trả về là:

Press ENTER or type command to continue
10

Bây giờ lợi dụng đặc điểm này, ta có thể viết một hàm trả về hai giá trị mà không dùng return, ví dụ tính diện tích và chu vi hình tròn với đầu vào là bán kính.

void circle(float r, float &perimeter, float &area) {
  const float PI = 3.14;
  
  perimeter = 2 * r * PI;
  area = r * r * PI;
}

int main() {
  float r = 5;
  float perimeter, area;
  
  circle(r, perimeter, area);
  cout << perimeter << endl;
  cout << area << endl;
  
  return 0;
}

2. Con trỏ

Variable (biến) là một ô nhớ hoặc một vùng nhớ được hệ điều hành cấp phát cho chương trình dùng để lưu giá trị vào ô nhớ.

Để truy xuất giá trị của biến đang nắm giữ, chương trình cần tìm tới địa chỉ của biến và đọc giá trị. Thông thường ta không cần quan tâm tới địa chỉ vùng nhớ, ta chỉ cần gọi định danh của biến để lấy ra giá trị cần.

Chương trình không thể truy xuất trực tiếp vào vùng nhớ vật lý mà chỉ truy xuất trên địa chỉ của vùng nhớ ảo, hệ điều hành sẽ quản lý một page-table để mapping một địa chỉ ảo sang địa chỉ vật lý. Còn việc truy xuất tới vùng nhớ vật lý từ vùng nhớ ảo sẽ do phần cứng MMU (memory management unit) quản lý.

virtual-memory

Ví dụ giá trị của biến và địa chỉ của biến

int main() {
  int x = 5;

  cout << x << endl;
  cout << &x << endl;

  return 0;
}
5
0x7ffee427d5d8

Press ENTER or type command to continue

Tham chiếu thì sao nhỉ?

int main() {
  int x = 5;
  int &ref = x;

  cout << &x << endl;
  cout << &ref << endl;
  
  return 0;
  
0x7ffeecb835c8
0x7ffeecb835c8

Press ENTER or type command to continue

2.1 Toán tử trỏ đến

Ta có toán tử trỏ đến (dereference operator) được kí hiệu bởi dấu * cho phép lấy giá trị của vùng nhớ có địa chỉ cụ thế. Ví dụ ta biết địa chỉ của biến x là 0x7ffeecb835c8, giờ thay vì gọi định danh để lấy giá trị, ta gọi địa chỉ để lấy giá trị.

int main() {
  int x = 5;
  
  // binh thuong lay gia tri qua dinh danh
  cout << x << endl;
  
  // lay gia tri qua dia chi &x
  cout << *(&x) << endl;
  
  return 0;
}

Kết quả đều giống nhau

5
5

Press ENTER or type command to continue

Ngoài việc dùng để truy xuất giá trị trong vùng nhớ có địa chỉ cụ thể, toán tử trỏ đến còn dùng để thay đổi giá trị của vùng nhớ như cách ta xài định danh

// binh thuong

int x = 5;
cout << x << endl;
x = 10
cout << x << endl;

// dung toan tu tro den

int y = 5;
cout << y << endl;
*(&y) = 10
cout << y << endl;
// or
cout << *(&y) << endl;

2.2 Con trỏ

Nhắc lại là biến tham chiếu thì có cùng địa chỉ với biến mà nó tham chiếu tới. Địa chỉ của &x giống địa chỉ của x.

Con trỏ khác với biến tham chiếu là nó là biến độc lập nên có địa chỉ khác với vùng nhớ mà nó trỏ tới. Nhưng giá trị bên trong của con trỏ lại chính là địa chỉ của biến mà nó trỏ tới.

  • Địa chỉ của con trỏ là &(*x) sẽ khác địa chỉ của biến x là &(x).
  • Giá trị của con trỏ *x chính là địa chỉ của biến x là &(x).

Khai báo và sử dụng

int *ptr;
int value = 5;

ptr = &value

Ở trên ta khai báo kiểu dữ liệu của con trỏ chính là kiểu dữ liệu của biến mà ta muốn trỏ tới, và vì con trỏ lưu địa chỉ vùng nhớ của biến mà nó trỏ tới nên ta cần dùng address-of operator để lấy địa chỉ của biến value trước khi gán cho con trỏ.

Bây giờ ta thử in ra địa chỉ vùng nhớ của con trỏ và địa chỉ vùng nhớ của biến mà con trỏ trỏ tới sẽ thấy là 2 giá trị khác nhau, như lúc đầu tiên nói.

int main() {
  int x = 5;
  int *ptr;

  ptr = &(x);

  cout << &x << endl;
  cout << &ptr << endl;

  return 0;
}
0x7ffee9df85d8
0x7ffee9df85d0

Press ENTER or type command to continue

Ok, cuối cùng quay trở lại với phép toán dereference operator, phép toán cho phép truy xuất giá trị của biến thông qua địa chỉ vùng nhớ của biến đó thay vì sử dụng định danh.

int main() {
  int x = 5;
  int *ptr;

  ptr = &(x);

  // truy xuat gia tri bien thong qua dinh danh
  cout << x << endl;
  
  // truy xuat gia tri cua bien thong qua dia chi cua bien, phep toan address-of operator
  cout << *(&x) << endl;
  
  // truy xuat gia tri bien thong qua dia chi cua bien luu trong con tro, tro toi bien
  cout << *ptr << endl;

  return 0;
}

Kết quả tất nhiên là như nhau:

5
5
5

Press ENTER or type command to continue

3. Ref

Bài viết tham khảo rất nhiều từ khóa học C++ Free của https://cpp.daynhauhoc.com, tuy nhiên với một người không làm chuyên về C++, mình lược bỏ một số phần theo mình là không cần thiết với mình cho đơn giản, dễ hiểu.