C++ Con trỏ (Pointer) toàn thư: Phần 2: Con trỏ với các cấu trúc dữ liệu căn bản.

Chủ nhật có bóng đá AFF bán kết Việt Nam vs Philippines; cũng muốn đi xem nhưng buồn cái là ko có người yêu! 😅 Thôi thì đằng nào cũng chẳng máu me bóng đá, nên lại ngồi viết tiếp seri Con trỏ vậy. Tự nhủ dù cuộc sống nhạt nhẽo nhưng còn có vài núi kiến thức còn chưa chinh phục được. 😀

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à tiếp theo, mình xin giới thiệu phần 2 của seri: Con trỏ với các cấu trúc dữ liệu căn bản.

Hằng con trỏ, con trỏ hằng?

Hằng là gì? Là những đại lượng có giá trị ko đổi theo thời gian. Trong C++ thì chúng có thể là 1 giá trị, hoặc 1 chuỗi ký tự; được khai báo thêm bằng từ khóa const; và chúng cũng phải có kiểu dữ liệu đi kèm! Chú ý rằng #define là định nghĩa macro, ko phải định nghĩa hằng! Từng đó là chưa đủ để hiểu hết về hằng ở trong C++ đâu; nhưng thôi mình sẽ ko sa đà quá, tập trung vào con trỏ thôi.

Hằng số là những biến số có giá trị cố định và ko có khả năng thay đổi sang giá trị khác. Do đó hằng con trỏ là những con trỏ chỉ có khả năng trỏ vào 1 vùng địa chỉ cố định, ko có khả năng thay đổi địa chỉ để trỏ sang vùng địa chỉ khác! Nhưng chúng có thể làm thay đổi giá trị của biến được lưu trong vùng địa chỉ đó! Giá trị của 1 con trỏ lưu giữ là địa chỉ vùng nhớ; nên nếu con trỏ đó là hằng thì nghĩa là giá trị của con trỏ đó ko thay đổi được, tương đương với việc ko thể thay đổi địa chỉ vùng nhớ mà con trỏ đó lưu giữ; tức là nó chỉ có thể trỏ vào vùng nhớ đó mãi mãi mà thôi!

Hằng con trỏ thì như vậy; thế con trỏ hằng là cái con khỉ j? Con trỏ hằng nôm na thì là nó trỏ vào hằng số; nói cách khác, nó là những con trỏ mà trỏ vào vùng địa chỉ chứa 1 hằng số; tức là nó có thể trỏ ra chỗ khác, nhưng nếu nó trỏ vào hằng số thì nó ko thể làm thay đổi giá trị của vùng địa chỉ chứa hằng số đó!

2 cái tên "hằng con trỏ" và "con trỏ hằng" là mình tiếp thu được khi tìm hiểu trên 1 forum, để phục vụ làm bài tập lớn môn Kỹ thuật lập trình hồi kỳ 2 năm 2. Thực ra, thuật ngữ chuẩn tiếng Anh của mấy loại con trỏ này phải là mutable pointer với immutable pointer cơ. Bạn có thể google search bằng thuật ngữ tiếng Anh để hiểu rõ hơn nhé!

Ví dụ về hằng con trỏ
Hãy đọc lại định nghĩa về hằng con trỏ ở trên và nhìn dòng code khai báo con trỏ p rồi ngẫm nghĩ! Con trỏ p muốn là hằng con trỏ thì nghĩa là p phải là hằng; do đó phải có từ khóa const ở trước p! Dấu * ở đây là thuộc quyền sở hữu của const p! Do đó ta có khai báo hằng con trỏ như trên. Tóm lại, để khai báo hằng con trỏ thì phải đặt dấu * đứng trước cụm const p để khẳng định p - giá trị của con trỏ - vùng địa chỉ mà con trỏ lưu giữ - là const!

Với khai báo hằng con trỏ, tức là ko thể trỏ sang vùng khác; nên phép toán p++; đương nhiên là lỗi rồi, vì nó chỉ định p trỏ lên 1 ô nhớ tiếp theo trong vùng nhớ ảo được cấp phát! Còn p[4] thì lại là phép toán lấy giá trị của vùng nhớ, nên ta có thể ++ hay --, thậm chí là thay đổi giá trị được; vì điều này ko vi phạm định nghĩa của hằng con trỏ!

Với con trỏ hằng, tôi có 2 ví dụ vì loại này rất hay được các lập trình viên mới nhầm, rồi ko hiểu vì sao lại lỗi!
Các bạn nghĩ p ở trên chỉ là 1 con trỏ bình thường, ko phải hằng con trỏ hay con trỏ hằng j, vì khi khai báo cũng như khởi tạo đều ko có từ khóa const đúng ko! Nope, đó là cú lừa của trình biên dịch đó! Về mặt bản chất một ngôn ngữ cấp thấp như C, rồi sau là C++, cái dòng khai báo + khởi tạo cho con trỏ p kia sẽ có trình tự là thế này:
  • Khai báo: Trình biên dịch hiểu là bạn đã khai báo ra 1 con trỏ p có kiểu dữ liệu là char*.
  • Cú lừa nằm ở đây: Vì xâu ký tự "u23vietnam" chưa được khai báo ở đâu cả, nên trình biên dịch sẽ xin cấp phát 1 vùng nhớ để lưu dữ liệu của xâu ký tự "u23vietnam" vào đó. Điều đáng sợ ở đây là trình biên dịch quy định vùng dữ liệu lưu xâu ký tự "u23vietnam" đó là vùng địa chỉ lưu 1 hằng! Tức là xâu ký tự "u23vietnam" là một hằng
  • Khởi tạo: Trình biên dịch sau khi đã xin cấp phát bộ nhớ và hiểu vùng nhớ đó lưu hằng xâu ký tự "u23vietnam", thì nó sẽ khởi tạo cho p trỏ đến vị trí đầu tiên trong vùng địa chỉ của xâu ký tự "u23vietnam", tức là trỏ vào 'u' của 'u23vietnam'. Tóm lại lúc này p được trình biên dịch quy định là đang trỏ vào 1 hằng! Nên trình biên dịch hiểu pcon trỏ hằng, cho dù trong cả câu lệnh khai báo + khởi tạo, ta ko nhìn thấy 1 cái từ khóa const khỉ gió nào cả!
Đó, vậy là các bạn đã hiểu cú lừa rồi, từ giờ phải hết sức cẩn thận và nhớ nhắc các newbie khác khi gặp cú lừa này nhé! Quay trở lại đoạn code trên, ta đã hiểu pcon trỏ hằng, vì nó trỏ vào 1 hằng xâu ký tự; do đó câu lệnh p++; ko gây lỗi, vì ta có thể cho p trỏ ra chỗ khác mà, hoàn toàn ko vi phạm định nghĩa con trỏ hằng

Còn 2 dòng vỡ mồm kia thì bạn hãy thử nghĩ xem, vì sao lại báo lỗi ở 2 dòng đó, rồi đọc tiếp cũng chưa muộn! Còn nếu chưa nghĩ ra hoặc lười nghĩ thì có thể đọc tiếp; đơn giản là thế này thôi! Vì p trỏ vào hằng xâu ký tự, là 1 hằng, nên theo định nghĩa con trỏ hằng thì p ko có quyền thay đổi giá trị được lưu trữ bởi vùng nhớ mà nó trỏ đến. Cả 2 câu lệnh sau đều có hành vi thay đổi giá trị của hằng xâu ký tự "u23vietnam", nên báo lỗi ở đây là đương nhiên rồi!

Thêm 1 ví dụ nữa về con trỏ hằng nào:
Trong đoạn code trên, vì đã khai báo và khởi tạo giá trị cho str là xâu ký tự "u23vietnam" nên trình biên dịch sẽ hiểu xâu str ko phải là 1 hằng xâu ký tự như trường hợp trước nữa; mà str lúc này sẽ chỉ là 1 xâu ký tự bình thường. Tiếp tục xét đến dòng khai báo + khởi tạo con trỏ p, ta thấy có sự xuất hiện của từ khóa const ko đứng ngay trước tên con trỏ p như trường hợp khai báo hằng con trỏ (có dấu lấy giá trị * của con trỏ đứng chắn giữa). Do đó p lúc này ko phải là hằng con trỏ!

Lưu ý rằng ở khai báo con trỏ p, việc bạn viết là const char hay là char const thì cũng ko có j khác biệt nhé; vì đó đều là việc khai báo ra 1 con trỏ, và chỉ định biến số được trỏ bởi con trỏ đó trở thành 1 hằng! Nghĩa là lúc này str đang từ 1 biến bị biến thành 1 hằng! (const char với char const thì như nhau; nhưng con cha vs cha con thì mình ko biết đâu nhé!)

Với định nghĩa về con trỏ hằng, đương nhiên câu lệnh p++; ko vấn đề j; vì ta hoàn toàn có thể cho p trỏ đi chỗ khác. Nhưng việc thay đổi giá trị của 1 thành phần trong str thông qua p[4]++; thì lại không được cho phép vì đã vi phạm định nghĩa con trỏ hằng!

Con trỏ với mảng.

Bản chất của việc khai báo mảng 1 chiều là cả một quá trình tương đương với việc xin cấp phát 1 vùng nhớ trong STACK với kích thước như trong khai báo; ngoài ra còn khai báo thêm ra 1 hằng con trỏ trỏ vào đầu vùng nhớ đó!

Ví dụ, với khai báo int a[100];, trình biên dịch sẽ có quy định như sau:
  • a là 1 hằng con trỏ, và trỏ vào phần tử đầu tiên a[0] trong mảng trên. a là 1 hằng con trỏ để tránh việc ông nào ngứa tay cho a trỏ vào 1 chỗ khác! Ngoài ra a có 1 điểm khác với các hằng con trỏ khác, đó là xuất phát từ việc a trỏ vào mảng, nên a sở hữu phép toán sizeof() để thu về kích thước của mảng a[100] mà con trỏ a trỏ tới.
  • a là 1 hằng con trỏ nên các phép toán có hành vi cố tình khiến a trỏ đến 1 vùng địa chỉ khác là hoàn toàn vô nghĩa và sẽ gây lỗi. (++, --, +=, -=, =)
  • a tương đương với &a[0]
  • *a tương đương với a[0]
  • a + i tương đương với &a[i]
  • *(a + i) tương đương với a[i]
  • Chú ý quan trọng, 3 phép toán sau là tương đương: i[a] == a[i] == *(a + i. Bạn có thể thấy cái này giống hệt như việc lấy địa chỉ ô nhớ thông qua offset trong Assembly: MOV AL,[BX + 3] ⇔ MOV AL, [BX] + 3 ⇔ MOV AL, 3[BX].

Con trỏ với xâu.

Xâu ký tự là 1 trường hợp riêng của mảng 1 chiều, ứng với kích thước của mỗi phần tử trong mảng là 1 byte; và ký tự cuối cùng bằng NULL, có mã ASCII là 0, hay còn được ký hiệu là '\0'.

Với xâu ký tự, các lập trình viên thường mắc phải 2 lỗi sai. Trong đó lỗi đầu tiên đã được mình nêu ra và giải thích rõ ràng cặn kẽ trong ví dụ đầu tiên về con trỏ hằng ở phần đầu tiên rồi. Đó là việc bạn cố tình thay đổi giá trị của 1 hằng; điều này là vô lý.

Sai lầm thứ 2 hay được mắc phải là việc cố tình thay đổi giá trị của 1 hằng con trỏ! Điều này dính dáng đến quá trình khai báo mảng 1 chiều như mình đã nói ở trên. Cụ thể ở ví dụ sau đây:
Đừng có chối rằng bạn chưa từng thử gán giá trị xâu ký tự vào mảng theo kiểu này nhé! (trừ khi bạn đã được ai đó lưu ý từ trước). Như ta đã nói ở phần Mảng, việc khai báo str có nghĩa là con trỏ str là 1 hằng con trỏ, và nó ko thể trỏ vào chỗ khác! Cộng thêm với xâu ký tự "hoang tuyet nhung" kia được trình biên dịch hiểu là 1 hằng xâu ký tự; thế mà bạn lại dám cho str trỏ vào đầu vùng nhớ khác; như vậy có to tội ko nhỉ!

Có điều, nếu bạn gán theo lệnh str[0] = "hoang tuyet nhung"; hoặc str[100] = {0}; thì hoàn toàn ko vấn đề j nhé. Điều này tương đương với 1 mảng 2 chiều thôi; khi đó str[0] sẽ là 1 con trỏ hằng, nó khác với str là 1 hằng con trỏ chỉ phục vụ cho mảng str[100].

Cuối cùng là 1 sai lầm nghiêm trọng đến mức "nó ko phải là 1 sai lầm, nhưng nó lại là 1 sai lầm"! Nghe có vẻ hại não nhỉ, nhưng thực tế nó là sự thật đó các bạn. Cũng như câu "Tư bản ko thể xuất hiện từ lưu thông và cũng ko thể xuất hiện ở bên ngoài lưu thông. Nó phải xuất hiện trong lưu thông và đồng thời ko phải trong lưu thông." của Các Mác vậy! Nhưng với trường hợp này của chúng ta, vấn đề dễ hiểu và thông não hơn nhiều với vấn đề trong Triết học kia nhé! 😂
Bạn nghĩ sao về đoạn code trên! Ko có lỗi cú pháp, ko có lỗi biên dịch, cũng ko có lỗi runtime nốt! Tóm lại chả có lỗi con khỉ j cả; nhưng ko bao giờ đoạn code ứng với điều kiện TRUE của if được thực thi, mà chắc chắn điều kiện FALSE của else sẽ được thực thi, cho dù 2 xâu ký tự bên trên có giống hệt nhau đi chăng nữa! Vậy là bạn chả biết mình sai ở đâu, sai vì cái j; vì chẳng thấy 1 lỗi khỉ gió nào ở đây cả! Chính vì thế nên tôi mới cho rằng đây là lỗi nguy hiểm nhất, vì ko có bất cứ cảnh báo nào cho chúng ta nên chúng ta cứ nghĩ mình làm đúng; thực ra thì đúng là chúng ta code ko sai tí j cả, nhưng cuối cùng các đoạn code của chúng ta vẫn chẳng được thực thi. Vì sao vậy; là vì chúng ta hiểu sai bản chất của phép toán so sánh ngang bằng 2 con trỏ!

Trong Phần 1, tôi đã nói; phép so sánh ngang bằng 2 con trỏ tức là việc xem xét xem 2 con trỏ đó có cùng trỏ tới 1 địa chỉ hay ko, hoặc kiểm tra xem con trỏ đang xét có trỏ vào NULL hay ko! Trong đoạn code trên, ta có đến 2 con trỏ đó! 1 con trỏ là hằng con trỏ str; con trỏ còn lại chính là hằng con trỏ của hằng xâu ký tự "hoang tuyet nhung" trong điều kiện if! Do 2 con trỏ này đều là hằng con trỏ, và chúng đều đã được trỏ vào vị trí cố định khác nhau rồi, nên việc so sánh địa chỉ của 2 con trỏ này luôn cho kết quả FALSE; do đó đoạn chương trình trong else sẽ được thực thi, chứ ko phải đoạn chương trình ứng với if TRUE!

Kết thúc Phần 2.

Phù, vậy là tôi đã viết xong toàn bộ nội dung về việc sử dụng Con trỏ khi tương tác với các cấu trúc dữ liệu cơ bản. Chủ yếu toàn là các sai lầm thường gặp. Đó cũng chính là 1 phần của những yếu tố khiến các lập trình viên cảm thấy ghét C++. Bạn có nhận thấy rằng độ phức tạp của Phần 2 được nâng cao hơn so với Phần 1 chứ! Đúng vậy, ở các phần về sau sẽ còn nâng cao và hại não hơn nữa; đòi hỏi bạn phải tự luyện thật nhiều để nhớ rõ bản chất của con trỏ, tránh gặp phải các sai lầm như tôi đã liệt kê trên đây.

Nói chung thì bạn có thể khai báo theo kiểu Stack cũng được, mà ko cần dùng con  trỏ. Tuy nhiên nếu số lượng đối  tượng bạn cần tạo ra trong chương trình là rất lớn (cỡ vài nghìn chẳng hạn), thì tin tôi đi, chắc chắn bạn sẽ bị tràn bộ nhớ STACK nếu ko dùng con trỏ. Cho dù bạn có config OS để tăng kích thước của STACK đi chăng nữa, thì dù cho bạn có 32GB RAM, máy tính của bạn cũng sẽ bị dùng hết chỗ RAM đó thôi! Trust me, I'm an Engineer!

Nhận xét

Đăng 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.