Toán tử tăng, giảm dùng cho con trỏ

Chào các bạn học viên đang theo dõi khóa học lập trình trực tuyến ngôn ngữ C++.

Trong bài học trước, chúng ta tạm dừng sau khi tìm hiểu những khái niệm cơ bản nhất khi sử dụng con trỏ trong C/C++, vẫn còn rất nhiều thứ cần phải nói khi nhắc đến con trỏ.

Một câu hỏi đặt ra là các phép toán khi sử dụng cho con trỏ có gì khác so với sử dụng các phép toán với các biến thông thường hay không?

Về mặt bản chất, giá trị lưu trữ bên trong vùng nhớ của con trỏ là địa chỉ, địa chỉ của một biến (hoặc vùng nhớ) có kiểu unsigned int (số nguyên không dấu), do đó, chúng ta có thể thực hiện các phép toán trên con trỏ. Nhưng kết quả của các phép toán thực hiện trên con trỏ sẽ khác các phép toán số học thông thường về giá trị và cả ý nghĩa.

Ngôn ngữ C/C++ định nghĩa cho chúng ta 4 toán tử toán học có thể sử dụng cho con trỏ: ++, --, +, và -.

Trước khi tìm hiểu về các toán tử toán học dùng cho con trỏ, chúng ta khai báo trước một biến thông thường và một biến con trỏ (có kiểu dữ liệu phù hợp để trỏ tới biến thông thường vừa được khai báo):

int value = 0;
int *ptr = &value;
Increment operator (++)

Như các bạn đã được học, increment operator (++) được dùng để tăng giá trị bên trong vùng nhớ của biến lên 1 đơn vị. Increment operator (++) là toán tử một ngôi, có thể đặt trước tên biến, hoặc đặt sau tên biến.

Bây giờ, chúng ta sử dụng toán tử (++) cho con trỏ ptr để xem kết quả:

cout << "Before increased: " << ptr << endl;
ptr++;
cout << " After increased: " << ptr << endl;

Kết quả:

  • Before increased: 0x00F9FEFC (heximal) tương đương 16383740 (decimal)

  • After increased: 0x00F9FF00 (heximal) tương đương 16383744 (decimal)

Địa chỉ mới của ptr lúc này là 16383744, giá trị này lớn hơn giá trị cũ 4 đơn vị. Đúng bằng kích thước của kiểu dữ liệu int mà mình dùng để khai báo cho biến value.

Như vậy, increment operator (++) sẽ làm con trỏ trỏ đến địa chỉ tiếp theo trên bộ nhớ ảo. Khoảng cách của 2 địa chỉ này đúng bằng kích thước của kiểu dữ liệu được khai báo cho con trỏ.

Giả sử cũng với địa chỉ ban đầu là 16383740, nếu con trỏ được khai báo là char *ptr; thì khi sử dụng toán tử (++), địa chỉ mới của con trỏ lúc này sẽ là 16383741.

Decrement operator (–)

Ngược lại so với increment operator (++), decrement operator (–) sẽ giảm giá trị bên trong vùng nhớ của biến thông thường đi 1 đơn vị. Đối với biến con trỏ, khi sử dụng decrement operator (–), nó sẽ làm thay đổi địa chỉ của con trỏ đang trỏ đến, giá trị địa chỉ mới sẽ bằng giá trị địa chỉ cũ trừ đi kích thước của kiểu dữ liệu mà con trỏ đang trỏ đến.

Để dễ hình dung, mình lấy lại ví dụ trên:

int value = 0;
int *ptr = &value;

cout << "Before decreased: " << ptr << endl;

ptr--;
cout << " After decreased: " << ptr << endl;

Kết quả:

  • Before increased: 0x0051FC24 (heximal) tương đương 5372964 (decimal)

  • After increased: 0x0051FC20 (heximal) tương đương 5372960 (decimal)

Như chúng ta thấy, địa chỉ mới nhỏ hơn 4 (bytes) so với địa chỉ ban đầu, 4 bytes này chính là kích thước kiểu dữ liệu int mà con trỏ được khai báo.

Giả sử cũng với địa chỉ ban đầu là 5372964, nếu con trỏ được khai báo double *ptr; thì sau khi sử dụng toán tử (–), địa chỉ mới của con trỏ sẽ là 5372956.

Addition operator (+)

Sử dụng increment operator (++) cho con trỏ chỉ có thể làm con trỏ trỏ đến địa chỉ tiếp theo trên bộ nhớ ảo bắt đầu từ địa chỉ ban đầu mà con trỏ đang nắm giữ. Trong khi đó, toán tử addition (+) cho phép chúng ta trỏ đến vùng nhớ bất kỳ phía sau địa chỉ mà con trỏ đang nắm giữ.

Xét đoạn chương trình sau:

int value = 0;
int *ptr = &value;

cout << ptr << endl;

ptr = ptr + 5;
cout << ptr << endl;

Kết quả:

  • Before added 5: 0x0087FE48 (heximal) tương đương 8912456.

  • After added 5: 0x0087FE5C (heximal) tương đương 8912476.

8912476 - 8912456 = 20 (bytes)

Như vậy, con trỏ ptr đã trỏ đến địa chỉ mới đứng sau địa chỉ ban đầu 20 bytes (tương đương với 5 lần kích thước kiểu int)

Chúng ta có thể sử dụng dereference operator để truy xuất trực tiếp giá trị bên trong các vùng nhớ ảo bất kỳ khi sử dụng toán tử (+).

int value = 0;
int *ptr = &value;

cout << ptr << " => "  << *ptr << endl;
cout << ptr + 10 << " => " << *(ptr + 10) << endl;
cout << ptr + 50 << " => " << *(ptr + 50) << endl;

Kết quả của đoạn chương trình này là:

Giá trị 0 ban đầu là của biến value đang nắm giữ, những giá trị rác phía sau là của các vùng nhớ khác nắm giữ, chúng ta không cần thông qua tên biến nhưng vẫn có thể truy xuất giá trị của chúng thông qua dereference operator.

Những giá trị này có thể do chương trình khác đang sử dụng, nhưng những vùng nhớ này chưa được truy xuất bởi các chương trình khác hoặc không phải vùng nhớ hệ thống quan trọng, nên chương trình của chúng ta vẫn có thể truy xuất đến giá trị bên trong những địa chỉ này. Nếu có 2 chương trình cùng truy cập đến một vùng nhớ, hệ thống sẽ xảy ra xung đột.

Lưu ý: Toán tử (+) chỉ cho phép thực hiện với số nguyên.

Subtraction operator (-)

Ngược lại so với toán tử (+).

  • Before subtracted 5: 0x002CF7E0 (heximal) tương đương 2947040

  • After subtracted 5: 0x002CF7CC (heximal) tương đương 2947020

2947040 - 2947020 = 20 (bytes)

Như vậy, con trỏ ptr đã trỏ đến địa chỉ mới đứng trước địa chỉ ban đầu 20 bytes (tương đương với 5 lần kích thước kiểu int).

Chúng ta có thể sử dụng dereference operator để truy xuất trực tiếp giá trị bên trong các vùng nhớ ảo bất kỳ khi sử dụng toán tử (-).

int value = 0;
int *ptr = &value;

cout << ptr << " => "  << *ptr << endl;
cout << ptr - 5 << " => " << *(ptr - 5) << endl;
cout << ptr - 10 << " => " << *(ptr - 10) << endl;

Kết quả của đoạn chương trình này là:

Giải thích tương tự khi sử dụng toán tử (+).

Lưu ý: Toán tử (-) chỉ cho phép thực hiện với số nguyên.

So sánh hai con trỏ

Ngoài các toán tử toán học, chúng ta còn có thể áp dụng các toán tử quan hệ khi sử dụng con trỏ. Giả sử chúng ta khai báo 2 con trỏ p1 và p2 như sau:

int value1, value2;

int *p1;
int *p2;

p1 = &value1;
p2 = &value2;

Con trỏ p1 trỏ đến value1 và con trỏ p2 trỏ đến value2. Chúng ta thực hiện lần lượt 6 phép so sánh:

cout << "Is p1 less than p2?             " << (p1 < p2) << endl;
cout << "Is p1 greater than p2?          " << (p1 > p2) << endl;
cout << "Is p1 less than or equal p2?    " << (p1 <= p2) << endl;
cout << "Is p1 greater than or equal p2? " << (p1 >= p2) << endl;
cout << "Is p1 equal p2?                 " << (p1 == p2) << endl;
cout << "Is p1 not equal p2?             " << (p1 != p2) << endl;

Kết quả chúng ta được như sau:

Trong đó, phép so sánh bằng (==) sẽ kiểm tra xem 2 con trỏ này có trỏ đến cùng một địa chỉ hay không.

Một số lưu ý khi sử dụng các toán tử dùng cho con trỏ

Vì các toán tử dùng cho con trỏ có ý nghĩa hoàn toàn khác so với việc áp dụng các toán tử lên giá trị hoặc biến thông thường. Chúng ta cần có cách sử dụng hợp lý để tránh gây nhầm lẫn hoặc gây rối mắt.

Lấy đoạn chương trình sau để làm ví dụ:

int n = 5;
int *p = &n; //p point to n

*p++;
p++;

int n2 = *p*n;

Đây là một số cách sử dụng các toán tử toán học cho con trỏ gây khó hiểu cho người đọc.

  • Lệnh *p++; sẽ thực hiện hai bước, đầu tiên là sử dụng toán tử dereference để truy xuất đến vùng nhớ tại địa chỉ mà con trỏ p đang nắm giữ, bước thứ hai là trỏ đến địa chỉ tiếp theo (đứng sau n).

  • Sau đó, chúng ta bắt gặp lệnh p++; có nghĩa là cho con trỏ p trỏ đến địa chỉ tiếp theo lớn hơn địa chỉ ban đầu 4 bytes (kích thước của kiểu int).

  • Dòng cuối cùng, chúng ta có phép gán giá trị của phép nhân *p và n cho biến n2.

Để chương trình rõ ràng hơn, chúng ta nên thêm các cặp dấu ngoặc vào chương trình tương tự như thế này:

int n = 5;
int *p = &n; //p point to n

(*p)++;
p++;

int n2 = (*p) * n;

Những cặp dấu ngoặc sẽ giúp phân biệt lúc nào chúng ta sử dụng giá trị là địa chỉ lưu trong con trỏ, lúc nào chúng ta sử dụng giá trị trong vùng nhớ mà con trỏ đang trỏ đến.


Tổng kết

Việc sử dụng toán các toán tử toán học cho biến con trỏ mà không có mục đích rõ ràng có thể gây xung đột vùng nhớ, có thể dẫn đến crash chương trình. Chúng ta thường sử dụng các toán tử toán học khi con trỏ trỏ đến mảng một chiều, vì mảng một chiều lưu trữ trên bộ nhớ ảo là một vùng nhớ mà những phần tử có địa chỉ liên tiếp nhau. Chúng ta sẽ tìm hiểu vấn đề này trong các bài học sau.


Hẹn gặp lại các bạn trong bài học tiếp theo trong khóa học lập trình C++ hướng thực hành.

Mọi ý kiến đóng góp hoặc thắc mắc có thể đặt câu hỏi trực tiếp tại diễn đàn.

www.daynhauhoc.com


Link Videos khóa học

5 Likes

phần in đậm là sai nhé. Chỉ có tăng giá trị của p chứ ko tăng giá trị của *p

kiểu viết này rất hay xài. Điển hình hàm char * strcpy (char * dst, const char * src); được viết là

char * cpy = dest;
while ((*cpy++ = *src++));
return dest;

nếu mà *p++ tăng giá trị của *p lên 1 đơn vị thì tiêu tùng rồi.

theo bảng này http://en.cppreference.com/w/cpp/language/operator_precedence thì toán tử postfix ++ được đánh giá trước toán tử deref *, nên *p++ được đánh giá là *(p++)

5 Likes

Tôi thấy đúng đó chứ ?

p++ thì mới tăng giá trị p (con trỏ sẽ trỏ tới địa chỉ tiếp theo)
*p++ thì tăng giá trị của *p (tăng giá trị vùng nhớ mà con trỏ đang trỏ tới).

Cái này là do mình nhìn không rõ nên viết nhầm cho cái trường hợp (*p)++; ở dưới đó bạn. Mình fix lại ở trên kia rồi.

*p++ là lấy ra giá trị tại địa chỉ p đang giữ xong rồi trỏ đến địa chỉ tiếp theo. Thế nên ý mình là khi cần tăng giá trị bên trong vùng nhớ đó lên 1 thì nên để thêm dấu ngoặc vào thành ( *p)++;

1 Like

Ví dụ này kì vậy bạn, sao lại Dereference địa chỉ vô định trong Stack
Với lại ví dụ so sánh lớn nhỏ của 2 con trỏ cũng không thể hiện ý nghĩa gì hết, nó cũng là kết quả không xác định :slight_smile:

Chỉ là mình để thêm vài cái biến để minh họa cho mấy cái toán tử vậy thôi. Còn toán tử so sánh dùng trong một số iterator của mấy cái class trong STL như vector, list…

Còn ví dụ này:

int n;
int *p = &n;

Biến n sau khi khai báo có địa chỉ cụ thể trên virtual memory, vậy con trỏ p trỏ đến địa chỉ của n là hoàn toàn xác định. Khi nào sử dụng con trỏ cho một vấn đề cụ thể thì nó mới có ý nghĩa được chứ.

Biến n đúng là được cấp phát trên stack, nhưng stack thì địa chỉ thực của nó vẫn nằm trên RAM thôi. Heap cũng vậy, heap nó cũng có địa chỉ thực là nằm trên RAM. Rồi cuối cùng thì stack và heap đều được quản lý dưới dạng bộ nhớ ảo. Nhưng hệ điều hành phân ra stack và heap để phân biệt một số điểm:

  • Những biến được cấp phát trên stack sau khi ra khỏi phạm vi thì tự động thu hồi vùng nhớ.
  • Những biến được cấp phát trên heap cần được thu hồi vùng nhớ một cách thủ công.

Nhưng có là vùng nhớ trên stack hay heap thì khi ứng dụng bị close thì hệ điều hành cũng thu hồi hết vùng nhớ đc cấp phát cho nó.

thoạt nhìn thì cũng tưởng vậy nhưng ko phải vậy đâu. *p++ khác (*p)++

(*p)++ tương đối vô ích, còn *p++ sử dụng rất nhiều. Vd hàm tính tổng 1 mảng thì có thể viết như sau:

int sum(int* beg, int* end)
{
    int ret = 0;
    while (beg != end) ret += *beg++;
    return ret;
}

*beg++ viết tắt, đỡ phải ghi 2 lần *beg rồi beg++. Lấy giá trị phần tử hiện tại, rồi tiếp tục qua phần tử tiếp theo. Cái này xài rất nhiều.

6 Likes

mình nói vô định vì mỗi lúc hàm đó được gọi thì n sẽ có địa chỉ khác dẫn đến việc p+1 không cố định nên Dereference p+1 sẽ không thực tế. Mình thấy lấy ví dụ về tăng giảm pointer thì nên dùng pointer to array sẽ hay và trực quan hơn. Mình góp ý chút vậy thôi.

1 Like

Thế nên mình có nói lại ở phần tổng kết là mấy cái toán tử này thường dùng cho mảng một chiều chứ không nên trỏ lung tung trong bộ nhớ ảo mà. Trước khi qua bài Pointer & array mình cần mấy bạn mới học hiểu mấy cái toán tử này trước.

1 Like

Chỗ này có nghĩa là lấy giá trị con trỏ p đang giữ, rồi gán cái giá trị đó vào vùng nhớ tiếp theo con trỏ p đúng không ạ?

*p++ thực hiện 2 bước:

B1: *p lấy giá trị tại địa chỉ được lưu trong con trỏ p, còn cái giá trị đó được gán cho ai thì còn tùy vào câu lệnh. Ví dụ:

int a = *p;

nghĩa là lấy cái giá trị đó để gán cho p, còn nếu ko gán cho ai cả mà chỉ làm thế này:

*p;

thì câu lệnh vẫn được thực thi, giá trị tại địa chỉ p cũng đc lấy ra nhưng không gán cho ai cả.

B2: Thực hiện p++, giá trị lưu trong p (là cái địa chỉ) tăng lên để trỏ đến vùng nhớ tiếp theo.

3 Likes

Theo như mình test thì *p++ và p++ sẽ cho kết quả giống nhau.
// PROGRAM 1
#include <stdio.h>
int main(void)
{
int arr[] = {10, 20};
int *p = arr;
*p++;
printf(“arr[0] = %d, arr[1] = %d, *p = %d”, arr[0], arr[1], *p);
return 0;
}
// PROGRAM 2
#include <stdio.h>
int main(void)
{
int arr[] = {10, 20};
int *p = arr;
p++;
printf(“arr[0] = %d, arr[1] = %d, *p = %d”, arr[0], arr[1], *p);
return 0;
}
kết quả đều là arr[0] = 10, arr[1] = 20, *p = 20
ko biết m hiểu có đúng ko?

Viết *p++ tức là *(p++) đó bạn :slight_smile:

Vậy hai code ra kq giống nhau.

1 Like

Mình thấy có lẽ nhiều người khó hiểu về *p++. Kết quả mình test trên vs 2019 như sau.
_ *p++ tương đương p sau đó p++
_
++p tương đương ++p sau đó *p
_ ++*p tương đương *p sau đó *p+=1
Nên khi kết hợp toán tử * với toán tử – hoặc ++ thì tốt nhất nên thêm dấu ( ) vào để dễ đọc, nếu cạnh con trỏ p là toán tử – hoặc ++ thì vẫn tuân theo quy luật tiền tố hậu tố, tốt nhất vẫn nên đặt trong dấu ( )

Thực chất *p++ là p sẽ trỏ đến vùng nhớ của arr[1] và lấy ra giá trị tại vùng nhớ này, nên tất nhiên *p sẽ là 20 vì *p++ ở trên bạn chưa thay đổi giá trị gì cả, nếu ngay đoạn đó bạn để *p++=70; thì *p sẽ là 70 và a[1] cũng là 70 luôn.
Và p++ cũng là trỏ đến vùng nhớ của a[1] luôn, tuy nhiên khi để p++ là bạn chỉ muốn đưa con trỏ p đến vùng nhớ khác chứ ko có ý định thay đổi lại giá trị tại vùng nhớ này như *p++
Lấy một ví dụ khác, vùng nhớ p++ tiếp theo không có giá trị nào thì đó *p sẽ cho ra giá trị rác. Nên *p++ và p++ bản chất nó khác nhau, bạn nên cẩn thận khi sử dụng. Còn nếu bạn ko thay đổi giá trị khi *p++ thì đúng là kết quả nó giống nhau đấy

Khác nhau chứ sao mà giống được https://ideone.com/xjdnoV

2 Likes

À à lỗi mình:>
Lúc đó đặt biến sai vị trí, để fix lại ở trên

83% thành viên diễn đàn không hỏi bài tập, còn bạn thì sao?