C++ Con trỏ (Pointer) toàn thư: Phần 3: Con trỏ với Hàm.
Sau 3 4 ngày j đó, hôm nào cũng phải ngồi xử lý bằng tay mớ logging từ Kibana của con SOC, đến hôm nay, sức chịu đựng của mình đã đạt đỉnh điểm và mình quyết định "Tao ĐÉO MUỐN tốn nửa tiếng xử lý dữ liệu mỗi 2 tiếng đồng hồ trôi qua nữa, ai cho tao lương thiện!". Vậy là trong sự tức tối đó (nhưng vẫn bình tĩnh vãi đái), mình quyết định code luôn 1 chương trình C++ để xử lý mớ logging file Excel của Kibana. Sau hơn 3 tiếng đồng hồ, cuối cùng mình cũng code xong. Quẩy lên; thay vì tốn nửa tiếng như trước, giờ mình chỉ tốn chưa đến 10s để xử lý file logging của Kibana.
Đó, các bạn thấy ko, cuối cùng động cơ để thúc đẩy thế giới phát triển chung quy cũng là 1 chữ LƯỜI! Nhưng điểm quan trọng nhất ở đây, trước khi LƯỜI, chúng ta cần phải "có kiến thức". Mà kiến thức thì học cả đời ko hết được! :sad:
1 điều cũng khá quan trọng ở đây, cần phải "chủ động". Khổng Tử đã dạy rằng "hãy chỉ cần nghĩ 2 lần thôi là đủ!". Mình thấy rất đúng. Nghĩ 1 lần thì có thể chưa suy tính trọn vẹn đc vấn đề; nhưng nếu nghĩ từ 3 lần trở lên thì dễ có khả năng sẽ làm trì hoãn các dự định đã được suy tính. Do đó 2 lần là đủ, để tránh tình trạng rây rưa trì hoãn ko đáng có.
Xàm thế đủ rồi, quay lại seri Con trỏ. Nhưng ở phần dàn bài mình đã đưa ra:
- Phần 1: Căn bản về Con trỏ.
- Phần 2: Con trỏ với các cấu trúc dữ liệu căn bản.
- Phần 3: Con trỏ với Hàm.
- Phần 4: Con trỏ "đa cấp"; đánh nhau bằng con trỏ.
- Phần 5: Con trỏ "thông minh" dành cho Lập trình viên "thông minh".
Nói qua một chút kiến thức có liên quan (nhưng chắc ko ai muốn nghe), nếu bạn đã từng code Hợp ngữ (Assembly) hay sử dụng OllyDbg để dịch ngược chương trình thì chắc chắn sẽ hiểu rằng các Hàm của chương trình cũng được biểu diễn về Assembly, sau đó được load lên bộ nhớ ảo mà OS (Hệ điều hành) đã cấp phát cho chương trình.
Do đó, Hàm cũng có địa chỉ trong vùng nhớ ảo. Mà đã là địa chỉ vùng nhớ ảo thì ko chỗ nào là Con trỏ ko thể mó tay vào được! Do đó Hàm cũng có thể được trỏ bởi 1 con trỏ.
Con trỏ Hàm.
Con trỏ Hàm cũng là con trỏ! Con trỏ có cái j thì con trỏ Hàm cũng có cái đó. Do đó con trỏ Hàm cũng có kiểu dữ liệu trả về. Kiểu dữ liệu của con trỏ hàm chính là kiểu dữ liệu con trỏ tương ứng với kiểu dữ liệu trả về của Hàm mà nó trỏ đến. Ví dụ nếu Hàm return về int thì kiểu dữ liệu của con trỏ Hàm sẽ là int*; nếu Hàm ko return thì kiểu dữ liệu sẽ là void*.
<return_type> (*<name_of_pointer>)( <data_type_of_parameters> );
Chưa hết, ngoài kiểu dữ liệu ra, những con trỏ Hàm còn cần được phân biệt với nhau bằng tham số hình thức. Một con trỏ Hàm muốn trỏ được vào một hàm nào đó thì kiểu dữ liệu và tham số hình thức giữa hàm và con trỏ Hàm phải tương ứng giống nhau! Tuy có chấp nhận việc ép kiểu (tường minh) nhưng không chấp nhận tham số mặc định! Lý do là vì tham số mặc định sẽ được compiler xác định trong khi compile; còn con trỏ Hàm thì cũng như con trỏ, sẽ được xác định trong khi chạy chương trình.
Trong chương trình trên:
- ta đã khai báo trước một hàm tìm min giữa 2 số; tên của nó là "min".
- int (*p)(int, int); là câu lệnh khai báo ra con trỏ Hàm có tên là p và có 2 tham số hình thức kiểu int.
- p = min; là câu lệnh khởi tạo giá trị cho con trỏ Hàm p; cho p nhận giá trị là địa chỉ của hàm min và trỏ vào hàm min.
- p(4, 5); là câu lệnh gọi hàm min thông qua con trỏ Hàm p đã trỏ vào min.
Ở ví dụ này, hàm min là hàm đã có sẵn trong chương trình; do đó ta chỉ cần khởi tạo và gán p = min; rất đơn giản. Nhưng trong thực tế, việc loading các hàm trong thư viện liên kết động (file .dll) không hề nằm trong cùng một chương trình; do đó sẽ cần phải chỉ định cho con trỏ p trỏ vào 1 địa chỉ cụ thể trên vùng nhớ ảo được OS cấp phát. Như ví dụ sau đây:
Trong ví dụ này, ta thấy rằng việc khai báo con trỏ Hàm cũng như việc gọi hàm thông qua con trỏ Hàm vẫn diễn ra bình thường giống như ở ví dụ đầu. Tuy nhiên trong câu lệnh khởi tạo:
- con trỏ p được chỉ định trỏ vào 1 giá trị địa chỉ cụ thể, chứ ko phải 1 cái tên hàm. Do đó ta có thể thấy rằng min cũng có giá trị tương ứng kiểu như 0x873AB.
- việc khởi tạo con trỏ hàm có xuất hiện hành vi ép kiểu tường minh. Điều này là cần thiết vì compiler không thể biết hàm ở địa chỉ 0x873AB có kiểu dữ liệu như nào, có giống với p hay ko!
- Với kiểu dữ liệu được ép về void (*)(int), thì void chính là kiểu dữ liệu trả về; (int) chính là tham số hình thức cùng dấu ngoặc bao quanh thể hiện rằng đây là con trỏ trỏ vào Hàm! Ngoài ra (*) thể hiện rằng đây là 1 con trỏ! Vì là ép kiểu, nên sẽ ko có tên con trỏ sau dấu *.
Hằng con trỏ Hàm.
Con trỏ Hàm cũng là một con trỏ; nên Hằng con trỏ Hàm cũng là một Hằng con trỏ! Các bạn nhớ lại về Hằng con trỏ khi khai báo mảng nhé; Hằng con trỏ Hàm cũng có điểm tương đồng với Hằng con trỏ Mảng!
Khi ta khai báo một mảng, 1 Hằng con trỏ sẽ được tạo ra để trỏ vào phần tử đầu tiên của mảng đó; tên của con trỏ đó chính là tên của mảng mà nó trỏ vào. Tương tự như vậy, khi ta khai báo 1 hàm, 1 Hằng con trỏ Hàm sẽ được tạo ra để trỏ vào địa chỉ của Hàm đó trong vùng nhớ ảo; tên của con trỏ đó chính là tên của hàm mà nó trỏ vào, cũng chính là tên của hàm mà ta đã khai báo.
Quay lại với những ví dụ ở phần trên, ta có 2 hàm trong chương trình là min và main, do đó cũng sẽ có 2 Hằng con trỏ Hàm có tên tương ứng là min và main được tạo ra để trỏ vào mỗi hàm tương ứng. Giá trị của chúng chính là địa chỉ của hàm đó trên vùng nhớ ảo được OS cấp phát, có dạng kiểu như 0x873AB vậy. Nếu không tin, bạn cứ std::cout << min << main; ra mà xem!
Con trỏ với Hàm.
Trong C++, ta có 3 cách để truyền giá trị của biến vào làm tham số trong hàm:
Bạn nghĩ rằng câu lệnh setToNull(p); sẽ làm p trỏ đến NULL? Không phải đâu nhé! Theo nguyên tắc đã trình bày ở trên, compiler sẽ tạo ra một bản sao của p để truyền vào hàm setToNull(), hàm này sẽ gán giá trị NULL cho bản sao, sau khi ra khỏi scope của hàm, con trỏ ptr biến mất và bản sao của p cũng biến mất. Vậy p gốc vẫn chẳng hề bị thay đổi, vẫn chẳng về NULL! 1 cú lừa cực mạnh! 😂
Vậy điều khỉ gió j đang xảy ra? Làm sao để làm cho con trỏ đó trỏ về NULL? Trước khi nói cái này, mình sẽ trình bày một ví dụ đơn giản hơn để các bạn có thể hiểu được con trỏ sẽ thay đổi được j. Tuy rằng bản gốc và bản copy tham số có địa chỉ khác nhau, nhưng nếu giá trị lưu giữ của 2 địa chỉ đó giống nhau, và đều cùng là địa chỉ của 1 vùng nhớ khác, thì ta có thể thay đổi được giá trị của biến được lưu trữ ở vùng nhớ khác đó!
Trong chương trình trên, tưởng tượng rằng sẽ có 1 bản sao của &n được tạo ra, bản sao này chứa giá trị là địa chỉ của biến n; bản sao này được truyền vào hàm foo(). Hàm foo thực hiện thay đổi giá trị của vùng nhớ nhận vào, tức là biến n. Do đó sau khi hàm được gọi, giá trị của n sẽ bằng 2. Lưu ý là compiler sẽ tạo ra bản sao của &n nhé, ko phải tạo bản sao của n đâu!
Đó, kiểu như là 2 con trỏ cùng trỏ vào 1 địa chỉ ấy mà! Do đó để làm cho con trỏ ở ví dụ trước trỏ về NULL thì ta phải tạo ra một bản sao địa chỉ, chứ ko phải bản sao của biến. Có điều với ví dụ trước, biến này đã là địa chỉ rồi, nên bản sao địa chỉ của nó sẽ phải là dạng tham chiếu của con trỏ, tức là con trỏ có nhiều tên; cũng kiểu như tham chiếu của biến tức là 1 biến có nhiều tên ấy mà. Hoặc cũng có thể là con trỏ của con trỏ. Cái này mình sẽ trình bày ở Phần 4: Con trỏ đa cấp. Đánh nhau bằng con trỏ.
Ngoài việc sử dụng con trỏ làm tham số cho Hàm ra, trong C++, ta cũng có thể cho Hàm return về:
Trong ví dụ trên, hàm foo sẽ trả về 1 con trỏ int*, lưu trữ địa chỉ vùng nhớ của một biến có giá trị là 6. Câu lệnh int *p = foo(6) sẽ chỉ định p trỏ vào vùng nhớ được trả về bởi hàm foo, tức là vùng nhớ có giá trị bằng 6. Vậy là sau câu lệnh khai báo kết hợp khởi tạo trên, *p sẽ có giá trị của vùng nhớ đó thông qua toán tử & trong lệnh return &var;.
- truyền theo tham trị: truyền trực tiếp giá trị của biến vào trong hàm; ví dụ foo(int x);
- truyền theo tham chiếu: truyền theo tên thay thế của biến (một biến có nhiều tên); ví dụ foo(int& x);
- truyền theo tham trỏ: truyền theo địa chỉ của biến; ví dụ foo(int* x);
- Khi hàm được gọi, một bản sao của danh sách các tham số truyền vào sẽ được tạo ra. Tức là mỗi tham số sẽ có 1 vùng nhớ mới là bản copy của vùng nhớ trước khi gọi hàm. Vùng nhớ này thuộc kiểu Stack; các thanh ghi điều khiển truy cập Stack sẽ được thay đổi; cụ thể là giá trị hiện tại của thanh ghi EBP - Extend Base Pointer sẽ được cất đi; sau đó được gán bằng giá trị hiện tại trên đỉnh Stack, tức là giá trị của thanh ghi ESP - Extend Stack Pointer; sau đó ESP được tăng/giảm tùy thuộc vào kiến trúc vi xử lý để tạo ra vùng Stack mới chứa các biến cục bộ của hàm. Các tham số của hàm sẽ được push lần lượt vào Stack mới này. Do đó, tương tác với giá trị truyền vào trong hàm tức là tương tác với các giá trị trong Stack mới này, ko phải trong Stack cũ. Cụ thể về cơ chế này, có lẽ mình sẽ trình bày chi tiết ở một bài viết nào đó liên quan đến lỗi Buffer Overflow (tấn công tràn bộ đệm); còn bây giờ cũng ko nên sa đà khỏi chủ đề chính.
- Hàm sẽ chỉ làm việc với vùng nhớ copy này, ko làm việc với vùng nhớ gốc. Nghĩa là nếu truyền theo tham trị thì giá trị các biến vẫn vậy (hàm swap là một ví dụ kinh điển).
Bạn nghĩ rằng câu lệnh setToNull(p); sẽ làm p trỏ đến NULL? Không phải đâu nhé! Theo nguyên tắc đã trình bày ở trên, compiler sẽ tạo ra một bản sao của p để truyền vào hàm setToNull(), hàm này sẽ gán giá trị NULL cho bản sao, sau khi ra khỏi scope của hàm, con trỏ ptr biến mất và bản sao của p cũng biến mất. Vậy p gốc vẫn chẳng hề bị thay đổi, vẫn chẳng về NULL! 1 cú lừa cực mạnh! 😂
Vậy điều khỉ gió j đang xảy ra? Làm sao để làm cho con trỏ đó trỏ về NULL? Trước khi nói cái này, mình sẽ trình bày một ví dụ đơn giản hơn để các bạn có thể hiểu được con trỏ sẽ thay đổi được j. Tuy rằng bản gốc và bản copy tham số có địa chỉ khác nhau, nhưng nếu giá trị lưu giữ của 2 địa chỉ đó giống nhau, và đều cùng là địa chỉ của 1 vùng nhớ khác, thì ta có thể thay đổi được giá trị của biến được lưu trữ ở vùng nhớ khác đó!
Trong chương trình trên, tưởng tượng rằng sẽ có 1 bản sao của &n được tạo ra, bản sao này chứa giá trị là địa chỉ của biến n; bản sao này được truyền vào hàm foo(). Hàm foo thực hiện thay đổi giá trị của vùng nhớ nhận vào, tức là biến n. Do đó sau khi hàm được gọi, giá trị của n sẽ bằng 2. Lưu ý là compiler sẽ tạo ra bản sao của &n nhé, ko phải tạo bản sao của n đâu!
Đó, kiểu như là 2 con trỏ cùng trỏ vào 1 địa chỉ ấy mà! Do đó để làm cho con trỏ ở ví dụ trước trỏ về NULL thì ta phải tạo ra một bản sao địa chỉ, chứ ko phải bản sao của biến. Có điều với ví dụ trước, biến này đã là địa chỉ rồi, nên bản sao địa chỉ của nó sẽ phải là dạng tham chiếu của con trỏ, tức là con trỏ có nhiều tên; cũng kiểu như tham chiếu của biến tức là 1 biến có nhiều tên ấy mà. Hoặc cũng có thể là con trỏ của con trỏ. Cái này mình sẽ trình bày ở Phần 4: Con trỏ đa cấp. Đánh nhau bằng con trỏ.
Ngoài việc sử dụng con trỏ làm tham số cho Hàm ra, trong C++, ta cũng có thể cho Hàm return về:
- giá trị
- tham chiếu
- con trỏ.
Trong ví dụ trên, hàm foo sẽ trả về 1 con trỏ int*, lưu trữ địa chỉ vùng nhớ của một biến có giá trị là 6. Câu lệnh int *p = foo(6) sẽ chỉ định p trỏ vào vùng nhớ được trả về bởi hàm foo, tức là vùng nhớ có giá trị bằng 6. Vậy là sau câu lệnh khai báo kết hợp khởi tạo trên, *p sẽ có giá trị của vùng nhớ đó thông qua toán tử & trong lệnh return &var;.
Thêm.
Ngoài những cái trên ra, chuẩn C++ 11 cung cấp cho chúng ta một kiểu dữ liệu có tên gọi là function trong thư viên functional của bộ thư viện STL. Kiểu dữ liệu này được dùng để thay thế cho việc sử dụng con trỏ Hàm. Cách sử dụng không khác j so với con trỏ hàm cả. Các bạn có thể tham khảo và sử dụng STL khi có thể nhé!
Nhận xét
Đăng nhận xét