C++ Con trỏ (Pointer) toàn thư: Phần 1: Căn bản về Con trỏ.

Trong bài "Dàn bài", mình đã đưa ra dàn bài về seri bài viết Con trỏ như sau:
Và đây chính là bài viết đầu tiên trong seri Con trỏ: Căn bản về Con trỏ.

Định nghĩa con trỏ.

Phải nhớ: "Con trỏ chỉ là 1 biến NGUYÊN"! Nó cũng là 1 biến, và giá trị của nó là 1 số NGUYÊN, thuộc tập SỐ NGUYÊN Z!

Trong chương trình C++, con trỏ lưu trữ giá trị nguyên là địa chỉ ảo của biến mà nó trỏ đến! Ví dụ như là 0x1a651995, 0x2cd139e, ... Bạn thấy đó, các số hexa kia chẳng qua là dạng viết ngắn gọn của địa chỉ ô nhớ ảo mà Hệ điều hành cung cấp cho vùng nhớ STACK của chương trình. Mà đã là địa chỉ thì nó phải là số nguyên rồi! 

Do đó, từ bây giờ, nếu có gặp khai báo data_type *p; thì bạn phải nhớ p cũng là 1 biến, nó là con trỏ, và giá trị của nó là 1 số nguyên!

Trong Hệ điều hành 32bit, con trỏ có độ dài 32bit, tương ứng với 8 chữ số khi biểu diễn dưới dạng hexa như trên. Vậy tương tự, trong Hệ điều hành 64bit, con trỏ có độ dài 64bit, tương ứng với 16 chữ số khi biểu diễn dưới dạng hexa? Không, đó lại là cú lừa đó! Bây giờ bạn sẽ thấy con trỏ trên các Hệ điều hành 64bit sẽ có độ dài 48bit! Nhưng nói chung phần này không ảnh hưởng tới việc bạn có thể sử dụng tốt con trỏ, do đó mình sẽ dành thời gian để viết riêng về phần này trong một bài viết nói về sự khác biệt giữa các Hệ điều hành 32bit và 64bit, cũng như sự khác biệt trong kiến trúc tập lệnh của các bộ vi xử lý 32bit và 64bit.

Con trỏ được sử dụng để? Đương nhiên là để chỉ trỏ loạn xạ trong vùng nhớ ảo mà Hệ điều hành cấp cho rồi! Haha! Vậy tại sao phải để nó chỉ trỏ loạn xạ? Lý do sâu sa là thế này.

Với các khai báo kiểu data_type var; biến var kia sẽ có kích thước cố định bằng kích thước của kiểu dữ liệu data_type. Người ta gọi varbiến tĩnh. Khi khai báo một biến tĩnh, 1 vùng nhớ trong STACK sẽ được dành ra để cấp phát cho biến tĩnh đó. Vùng nhớ này nếu không lưu trữ biến cục bộ của 1 phạm vi (scope), hay nói cách khác, nó được cấp phát cho 1 biến toàn cục, thì nó sẽ cứ tồn tại mãi mãi từ lúc biên dịch chương trình, cho đến khi chương trình chạy xong; cho dù trong toàn bộ quá trình dài này, biến toàn cục đó chỉ được sử dụng duy nhất 1 lần; hoặc có thể thốn hơn là như kiểu "tao cứ khai báo ra đó thôi, chứ có chỗ nào dùng đâu!".

Nhưng chưa hết, tuy rằng nếu cấp phát dư chỉ gây ra việc lãng phí bộ nhớ thì ko nói làm j; vì ít nhất ngày này, sức mạnh của máy tính cũng không còn phải lo lắng quá nhiều về việc đó nữa. Nhưng thốn là thốn ở việc nếu cấp phát thiếu thì chương trình sẽ crash khi chạy. Để hạn chế việc gặp phải các vấn đề trên, C++ đã cung cấp thêm một kiểu biến nữa, gọi là biến động. Biến này sẽ được khai báo khi có từ khóa new hoặc malloc đi kèm. Và khi khai báo 1 biến động, 1 vùng nhớ trong HEAP sẽ được dành ra để cấp phát cho biến động đó. Ngoài ra, việc cấp phát chỉ xảy ra khi chương trình chạy, còn khi biên dịch thì ko có cấp phát! Hơn nữa, kích thước vùng nhớ được cấp phát có thể được thay đổi để tránh lãng phí bộ nhớ hoặc crash chương trình!

Có điều, vùng nhớ HEAP là vùng nhớ ảo và không có địa chỉ cố định thật trên RAM hoặc Ổ cứng; nên C++ tiếp tục tạo ra POINTER - CON TRỎ để chỉ trỏ vào những vùng địa chỉ ảo này. Kích thước của con trỏ luôn cố định (32bit vs 48bit mình nói bên trên ấy), không phụ thuộc vào kiểu dữ liệu của biến mà nó trỏ đến.

Do vậy, chốt lại câu chuyện dài trên, con trỏ có thể giúp ta:
  • Sử dụng được ưu điểm của vùng nhớ HEAP.
  • Thao tác gián tiếp với biến cũng như các cấu trúc dữ liệu phức tạp mà chỉ cần thông qua con trỏ.
  • Dọn rác.
  • Hiện thực hóa trên C++ các ý tưởng táo bạo của các ngôn ngữ mềm dẻo khác, như Callback, Lambda Expression, ...! Đây là ứng dụng của con trỏ hàm!
  • Vân vân và mây mây, thấy tiện tay thì dùng, IDE báo lỗi khai báo thì dùng, copy code trên mạng về thấy họ dùng nên cũng dùng, thấy ngầu thì dùng, ...!

Khai báo con trỏ.

Con trỏ được khai báo theo cấu trúc: kieu_du_lieu *ten_con_tro;

Kiểu dữ liệu ở đây là kiểu dữ liệu của vùng nhớ mà nó trỏ tới, có thể là:
  • Các kiểu dữ liệu có sẵn: void, boolean, char, short, int, unsigned int, float, long, double, ...
  • Các kiểu dữ liệu do lập trình viên tự định nghĩa: struct, union, class.
  • Các kiểu dữ liệu kết hợp khi sử dụng con trỏ hàm.
Sau khi có khai báo trên, ten_con_tro chính là con trỏ; còn *ten_con_tro thì lại là 1 giá trị với kiểu dữ liệu được khai báo nhé, chứ nó ko phải là con trỏ đâu! Ngoài ra khuyến nghị nên đặt dấu hoa thị (*) ở gần với ten_con_tro để tránh nhầm lẫn kieu_du_lieu* là 1 khai báo con trỏ!

Ví dụ, với khai báo float *a, *b; thì:
  • ab là 2 con trỏ.
  • *a*b ko phải con trỏ, nó là biến float!
Còn ông nào khai báo kiểu float* a, b; thì:
  • a là con trỏ, còn b là biến float.
  • *a là biến float, và ko có cái j gọi là *b ở đây đâu nhé! Đừng có nghĩ khai báo float* là có thể khai báo 1 loạt con trỏ trỏ đến biến float!
  • Nói thêm là tôi rất ghét thằng nào khai báo kiểu này!

Khởi tạo con trỏ.

Chú ý nhé, khai báo và khởi tạo là 2 công việc hoàn toàn khác nhau nhé! 
  • Khai báo là việc bạn thông báo cho trình biên dịch biết có sự xuất hiện của 1 biến mới. Ví dụ int a; là 1 khai báo cho biến a kiểu int.
  • Khởi tạo là việc bạn gán giá trị cho 1 biến mới chỉ được khai báo! Ví dụ như với biến a trên, câu lệnh a = 69; là việc khởi tạo giá trị ban đầu của a69.
  • Bạn hoàn toàn có thể kết hợp cả khai báo khởi tạo vào trong cùng 1 câu lệnh. Ví dụ câu lệnh int a = 69; thể hiện rằng bạn khai báo ra biến a và sau đó khởi tạo luôn giá trị ban đầu bằng 69 cho biến a đó.
Với con trỏ, việc khởi tạo có cấu trúc: ten_con_tro = dia_chi;

Tên con trỏ ở đây nhớ là ko có dấu hoa thị ở đầu đâu nhé! Còn địa chỉ thì chính là địa chỉ mà ta muốn con trỏ nó trỏ đến; nôm là thì khi thể hiện ra, nó sẽ là các số hexa ở trên đầu bài viết ấy!

Nên nhớ khi ta khai báo con trỏ, thì ten_con_tro là con trỏ, và nó cũng là 1 biến nguyên (kích thước 32bit hoặc 48bit), do đó nó cũng nằm trong bộ nhớ và cũng có địa chỉ riêng.

Ví dụ với 2 câu lệnh int a = 69; int *p = &a; thì:
  • con trỏ p được khởi tạo giá trị là trỏ đến địa chỉ của biến a.
  • Toán tử & là toán tử 1 ngôi, khác với toán tử &(AND) trong các toán tử bitwise. Toán tử & 1 ngôi này có tác dụng lấy ra địa chỉ của biến. Kiểu scanf("%d", &a); ấy!
  • a*p phải có cùng kiểu dữ liệu, đó là int
  • Nghĩa là con trỏ int thì phải trỏ vào biến int, con trỏ float thì phải trỏ vào biến float. Nhưng con trỏ void thì thích trỏ đi đâu cũng được, bất cứ đâu, từ biến cho đến hàm!
Lúc này, ta có thể coi *p với a là một; tức là mọi thao tác và thay đổi với *p cũng tương đương như thao tác và thay đổi với a! Ta nói *p lúc này chính là 1 bí danh của a. Toán tử * ở đây cũng là toán tử 1 ngôi (ko phải toán tử nhân 2 ngôi đâu nhé), có tác dụng truy xuất đến ô nhớ mà con trỏ đang trỏ đến:
  • a = 96; tương đương với *p = 96;
  • a++; tương đương với (*p)++;. Và ta phải sử dụng dấu ngoặc đơn, vì toán tử ++ có độ ưu tiên rất cao, cao hơn *.
  • Chú ý rằng với câu lệnh p++; thì có nghĩa là ta chỉ định con trỏ p trỏ vào vùng nhớ bên cạnh vùng nhớ lưu biến a!
  • Do đó, câu lệnh scanf("%d", &a); có thể được viết lại thành scanf("%d", p);

Kiểu dữ liệu con trỏ.

Như một ví dụ bên trên là ứng với khai báo float *a, b;:
  • Chúng ta luôn phải viết * gần tên biến, vì dấu * nay là thuộc sở hữu của con trỏ!
  • b ko phải con trỏ, kiểu dữ liệu của bfloat!
  • *a ko phải con trỏ; *a có kiểu dữ liệu là float.
  • a mới là con trỏ. Và hãy nhớ rằng kiểu dữ liệu của a(float*)! Là 1 "con trỏ float"!
Do đó, trong một khai báo kiểu này char* get(char *str);:
  • Trong câu lệnh khai báo đối số của hàm get(), dấu * phải đứng gần con trỏ!
  • Kiểu dữ liệu trả về của hàm get() là kiểu dữ liệu con trỏ char, phải viết * gần kiểu dữ liệu!
  • Lưu ý rằng đây là cách viết chuẩn mực của Microsoft; nên bạn có thể tuân theo hoặc ko! Nhưng nếu ko tuân theo, code của bạn sẽ gây ức chế rất mạnh cho người khác, có khi cho chính bạn sau này nữa! 
Vậy, với kiểu dữ liệu con trỏ, ta có thể có các phép toán thao tác nào?

Đầu tiên là phép gán. Ví dụ với con trỏ int *p;, câu lệnh gán p = (int*) 1995;:
  • p sẽ trỏ đến ô nhớ thứ 1995 trong vùng nhớ ảo mà Hệ điều hành cấp cho chương trình.
  • 1995 là 1 số nguyên, do đó phải thực hiện ép kiểu tường minh từ int về con trỏ int (tức int*).
  • Chú ý, với con trỏ kiểu void (void*), phép gán ko nhất thiết phải yêu cầu sự tương xứng kiểu dữ liệu! void* có thể được gán bởi tất cả các kiểu dữ liệu con trỏ khác!
Với phép so sánh:
  • Ta thực hiện so sánh ngang bằng (==) để kiểm tra xem 2 con trỏ có cùng trỏ đến 1 địa chỉ hay ko; hoặc kiểm tra 1 con trỏ có đang tro vào vô định (NULL) hay ko (khi bạn mở file chẳng hạn).
  • Các phép so sánh ko ngang bằng (>, <, >=, <=, !=) dùng để kiểm tra về độ cao thấp giữa 2 địa chỉ được trỏ đến bởi 2 con trỏ đem so sánh. Con trỏ nào trỏ vào địa chỉ có giá trị thấp hơn thì thấp hơn.
  • Được quyền so sánh mọi con trỏ với 0, vì 0 ứng với NULL, nghĩa là ko trỏ đến vùng nhớ nào cả.
  • Lưu ý, khi thực hiện so sánh, phải tiến hành ép kiểu trước khi so sánh, trừ con trỏ void (void*).
Với phép tăng/giảm giá trị (+, +=, ++, -, -=, --):
  • Việc tăng/giảm giá trị con trỏ có nghĩa là dịch chuyển con trỏ để trỏ đến các địa chỉ bên cạnh trong vùng nhớ ảo được Hệ điều hành cấp phát.
  • Lưu ý rằng việc tăng/giảm con trỏ đi 1 đơn vị (++ hoặc --) ko có nghĩa là cho con trỏ trỏ đến ô nhớ bên cạnh, mà là trỏ đến địa chỉ bên cạnh! Tức là phụ thuộc vào kích thước của biến mà con trỏ trỏ đến (thông qua toán tử sizeof(*p)).
  • Không có phép tăng giảm trên con trỏ voidcon trỏ hàm!
  • Không có phép cộng 2 con trỏ với nhau.
  • Phép trừ 2 con trỏ sẽ cho ra độ lệch (offset) giữa 2 địa chỉ được trỏ bởi 2 con trỏ trong vùng nhớ ảo.

Kết phần 1.

Trên đây là toàn bộ những j basic nhất bạn cần phải biết, nhớ và hiểu nếu muốn đọc tiếp; cũng như muốn sử dụng được con trỏ, để đỡ ghét C++.

Nhận xét

Bài đăng phổ biến từ blog này

Trên con đường tu đạo luôn cực kỳ theo đuổi!

C++ Con trỏ (Pointer) toàn thư: Phần 4: Con trỏ "đa cấp". Đánh nhau bằng con trỏ.

Vừa ngộ ra sự vi diệu của Padding Oracle Attack thì được tin crush hồi lớp 12 sắp cưới.