Chuyển tới nội dung chính

Chương 3: Đa năng hóa toán tử

3.1. Giới thiệu tính năng và cú pháp khai báo

     Nếu bạn đã học qua lập trình C++ cơ bản, chắc chắc rằng trong hầu hết các bài tập về C++, bạn đều sử dụng các toán tử số học như cộng, trừ, nhân, chia. Hầu hết các toán tử đó đều được thực hiện trên toán hạng có kiểu dữ liệu cơ bản như int, float, double…

int a + b; 
int b = 4;
int c = a + b;

Vậy nếu như bạn muốn thực hiện các toán tử đó đối với toán hạng có kiểu dữ liệu bạn tự định nghĩa thì làm sao?

PhanSo ps1(1, 2);
PhanSo p2(2, 3);

Đây chính là lúc chúng ta sử dụng nạp chồng toán tử.

3.1.1. Nạp chồng toán tử là gì?

     Cũng tương tự như nạp chồng hàm (overload function), bạn có thể định nghĩa nhiều hàm có cùng tên, nhưng khác tham số truyền vào. Nạp chồng toán tử (overload operator) cũng tương tự, bạn định nghĩa lại toán tử đã có trên kiểu dữ liệu người dùng tự định nghĩa để dể dàng thể hiện các câu lệnh trong chương trình.

Ví dụ như bạn định nghĩa phép cộng cho kiểu dữ liệu phân số thì sẽ thực hiện cộng hai phân số rồi trả về một phân số mới. So với việc thực hiện gọi hàm, việc overload toán tử sẽ làm cho câu lệnh ngắn gọn, dễ hiểu hơn:

PhanSo ps1(1, 2); 
PhanSo ps2(2, 3);
PhanSo ketQua;
ketQua = ps1.Tong(ps2); // Dùng hàm
ketQua = ps1 + ps2; // Dùng Overload operator

3.1.2. Cơ chế hoạt động

Về bản chất, việc thực hiện các toán tử cũng tương đương với việc gọi hàm, ví dụ:

PhanSo a(1, 2); 
PhanSo b(2, 3);
PhanSo ketQua = a + b;
// Tương đương với
PhanSo ketQua = a.operator+(b);

Nếu bạn thực hiện toán tử trên hai toán hạng có kiểu dữ liệu cơ bản (float, double, int …), trình biên dịch sẽ tìm xem phiên bản nạp chồng toán tử nào phù hợp với kiểu dữ liệu đó và sử dụng, nếu không có sẽ báo lỗi.

Ngược lại nếu là kiểu dữ liệu tự định nghĩa như struct, class, trình biên dịch sẽ tìm xem có phiên bản nạp chồng toán tử nào phù hợp không? Nếu có thì sẽ sử dụng toán tử đó, ngược lại thì sẽ cố gắng chuyển đổi kiểu dữ liệu của các toán hạng đó sang kiểu dữ liệu có sẵn để thực hiện phép toán, không được sẽ báo lỗi.
Các toán tử có thể overload: alt text

3.1.3. Cú pháp overload

     Như đã giới thiệu, bản chất việc dùng toán tử là lời gọi hàm, do đó chúng ta overload toán tử cũng giống overload hàm, vậy chúng ta sẽ overload hàm nào? Chúng ta sẽ overload hàm có tên là “operator@”, với @ là toán tử cần overload (+, -, *, /, …). Có hai loại là hàm cục bộ (dùng phương thức của lớp)hàm toàn cục (dùng hàm bạn). Chúng ta sẽ lần lượt tìm hiểu cách overload toán tử bằng cả hai cách.

Cài đặt với hàm cục bộ

     Đối với hàm cục bộ hay còn gọi là phương thức của lớp, số tham số sẽ ít hơn hàm toàn cục một tham số vì tham số đầu tiên mặc định chính là đối tượng gọi phương thức (toán hạng đầu tiên). Vậy, đối với toán tử hai ngôi, ta chỉ cần truyền vào một tham số cho hàm, chính là toán hạng thứ hai. Ví dụ:

class PhanSo { 
private:
int tu;
int mau;
public:
PhanSo() { tu = 0; mau = 1; }
PhanSo(int a, int b) { tu = a; mau = b; }
PhanSo operator+(const PhanSo& ps){ // overload toán tử +
PhanSo kq;
kq.tu = this->tu * ps.mau + ps.tu * this->mau;
kq.mau = this->mau * ps.mau;
return kq;
}
};

Sau khi overload toán tử, bạn có thể sử dụng nó trên kiểu dữ liệu bạn đã định nghĩa:

PhanSo ps1(1, 2); 
PhanSo ps2(2, 3);
PhanSo ps3 = ps1 + ps2; // ps3 = ps1.operator+(ps2)

Giờ chúng ta hãy xem một ví dụ khác, overload toán tử cộng một phân số với một số nguyên:

PhanSo operator+(const int& i) { 
PhanSo kq;
kq.tu = this->tu + i * this->mau;
kq.mau = this->mau;
return kq;
}
// Sử dụng
PhanSo ps1(1, 2);
PhanSo ps2 = ps1 + 2; // ps2 = ps1.operator+(2)

Do toán tử overload theo cách này là phương thức, được gọi từ một đối tượng, do đó mặc định toán hạng đầu tiên phải là toán hạng có kiểu dữ liệu của lớp đó, điều này cũng có nghĩa là bạn phải đặt toán hạng có kiểu dữ liệu của lớp đó đầu tiên rồi mới đến toán hạng tiếp theo. Và đối với các kiểu dữ liệu có sẵn, ta không thể truy cập vào các lớp định nghĩa nên chúng và overload operator của chúng được. Vậy để giải quyết điều này thì làm như thế nào? Ta sẽ sử dụng hàm toàn cục.

Cài đặt hàm toàn cục

     Thay vì để toán hạng đầu tiên luôn phải có kiểu là một lớp đối tượng, chúng ta sẽ sử dụng hàm bạn để có thể tự do lựa chọn thứ tự của các toán hạng. Ví dụ như bạn muốn 1 + ps1, ps1 + 1 đều được c

class PhanSo {  
//...
friend PhanSo operator+(const PhanSo& ps, const int& i);
friend PhanSo operator+(const int& i, const PhanSo& ps);
// đổi chỗ thứ tự toán hạng bằng cách đổi thứ tự tham số
};
PhanSo operator+(const PhanSo& ps, const int& i) {
PhanSo kq;
kq.tu = ps.tu + i * ps.mau;
kq.mau = this->mau;
return kq;
}
PhanSo operator+(const int& i, const PhanSo& ps) {
return ps + i;
}

Lúc này ta có thể thực hiện các phép toán sau:

PhanSo a(2,3); 
a + 5; // operator+(a,5)
5 + a; // operator+(5,a)

Nhưng vẫn còn 1 nhược điểm đó là ta phải nạp chồng operator+ nhiều lần. Vấn đề này sẽ được giải quyết bằng phương pháp chuyển kiểu.

3.1.4. Chuyển kiểu

Có hai loại chuyển kiểu là chuyển kiểu bằng constructor và bằng toán tử chuyển kiểu

Chuyển kiểu bằng constructor

// Thêm constructor bên trong lớp
PhanSo(int a) {
tu = a;
mau = 1;
}

Với constructor được định nghĩa như trên, khi ta thực hiện cộng một số nguyên với một kiểu phân số, hay một kiểu phân số với số nguyên thì số nguyên sẽ được trình biên dịch chuyển thành kiểu phân số thông qua việc gọi constructor bên trên, với mẫu số là 1 và tử chính là số nguyên ta đang cộng. Vì thế, lúc này chúng ta chỉ cần một hàm bạn:

class PhanSo {
friend PhanSo operator+(const PhanSo& ps1,const PhanSo& ps2)
//...
};
PhanSo operator+(const PhanSo& ps1, const PhanSo& ps2) {
PhanSo kq;
kq.tu = ps1.tu * ps2.mau + ps1.mau * ps2.tu;
kq.mau = ps1.mau * ps2.mau;
return kq;
}

Và chỉ cần như thế, lúc này ta có thể thực hiện như sau:

PhanSo a(2,3), b(4,1), c;
c = a + b; // c = operator+(a,b)
c = a + 5; // c = operator+(a,PhanSo(5))
c = 3 + a; // c = operator+(PhanSo(3),a)

Chuyển kiểu bằng toán tử chuyển kiểu

Dùng để chuyển kiểu dữ liệu ta định nghĩa sang các kiểu dữ liệu cơ bản. Cú pháp:

operator KieuDL(){
return x;
}

Ví dụ như mình muốn chuyển phân số về số thực, mình sẽ overload toán tử chuyển kiểu float

class PhanSo {
//...
operator float();
};
// Định nghĩa
PhanSo::operator float() {
return (float)this->tu / this->mau;
}

Lúc này ta có thể chuyển kiểu dữ liệu như sau:

PhanSo ps1(1, 2);
PhanSo ps2(2, 3);
float a = ps1 + ps2;
cout << a << endl; // ~ 1.67
cout << (float)ps1; // = 0.5

3.1.5. Sự nhập nằng

     Sự nhập nhằng xảy ra khi lớp của bạn có chuyển kiểu bằng constructor lẫn chuyển kiểu bằng toán tử chuyển kiểu. Nó khiến cho trình biên dịch không xác định được nên chuyển kiểu bằng cái nào, dẫn đến việc mất đi cơ chế chuyển kiểu tự động (ngầm định).

class PhanSo {
//...
public:
PhanSo(int a);
operator float();
};
int main(){
PhanSo a(2,3);
a + 5; // // lỗi do sự nhập nhằng, không biết nên chuyển
5 + a; // 5 thành PhanSo hay a thành float
}

Cách xử lý duy nhất cho việc này là thực hiển chuyển kiểu tường minh, việc này làm mất đi sự tiện lợi của cơ chế chuyển kiểu tự động. Do đó khi thực hiện chuyển kiểu, ta chỉ được chọn một trong hai, hoặc là chuyển kiểu bằng constructor, hoặc là overload toán tử chuyển kiểu.

3.2 Toán tử nhập, xuất (Input, output operator)

     Để nạp chồng toán tử nhập xuất,chúng ta sử dụng hàm cục bộ(và là hàm bạn), có hai tham số, tham số đầu tiên là một tham chiếu 1 đến đối tượng kểu istream hoặc ostream , tham số thử hai là một tham chiếu tới đối tượng cần nhập/xuất, kiểu trả về của hàm chính là tham chiếu đến tham số đầu tiêu của hàm ( istream hoặc ostream )

3.2.1. Toán tử nhập

Chúng ta sẽ thực hiện overload toán tử nhập cho lớp phân số như sau:

class PhanSo{
//...
friend istream& operater>>(istream&, PhanSo&)
};
istream& operator>>(istream& is, PhanSo& ps){
cout<< "Nhap tu: ";
is >> ps.tu;
cout<< "Nhap mau: ";
is >> ps.mau;
return is;
}

Như vậy toán tử nhập dã được nạp chồng cho lớp PhanSo

PhanSo ps; 
cin >> ps;

Hàm nạp chồng toán tử nhập về tham chiếu tới một đối tượng thuộc lớp istream để ta sử dụng các toán tử nhập một cách liên tiếp. Ví dụ:

PhanSo ps1,ps2;
cin >> ps1 >> ps2; // operator>>(operator>>(cin,ps1),ps2)

Trong ví dụ trên hàm operator>> được thực hiện trước, hàm này trả về một tham chiếu tới đối tượng cin, đối tượng này được sử dụng để làm đối số cho lời gọi hàm operator>> tiếp theo.

3.2.2. Toán tử xuất

Đối với toán tử xuát chúng ta cũng thực hiện tương tự như sau:

class PhanSo{
//...
friend ostream& operator>>(ostream&, PhanSo&)
};
ostream& operator>>(ostream& os, PhanSo& ps){
os << ps.tu << "/" << ps.mau;
return os;
}

Khác biệt duy nhất nằm ở chỗ tham số thứ 2 của hàm này nên là một tham chiếu hằng2 nhằm tránh việc phải thực hiện sao chép quá nhiều (tốn tài nguyên) đồng thời vẫn đảm bảo đối số truyền vào sẽ không bị thay đổi. Bây giờ bạn có thể sử dụng toán tử xuất bình thường như các kiểu dữ liệu cơ bản khác:

PhanSo ps1(1,2);
cout << ps;

Một số toán tử so sánh thường gặp: ==, !=, >, <, >=, <= . Một phương thức đa năng hóa toán tử so sánh thường trả về giá trị true(1)hay false(0). Điều này phù hợp với ứng dụng thông thường của những toán tử này (sử dụng trong các biểu thức điều kiện). Ví dụ: Định nghĩa toán tử so sánh > cho phân lớp PhanSo

  //Khai báo lớp,các thuộc tính,phương thức
bool PhanSo::operator>(const PhanSo& x){
float gt1 = (float)tu / mau;
float gt2 = (float)x.tu / x.mau;
if (gt1 > gt2)
return true;
return false;
}

Trong đoạn chương trình sau:

PhanSo a(2,3) , b(4,1);
if (a > b)
cout << "Phan so a lon hon phan so b" << endl;
else
cout << "Phan so a khong lon hon phan so b" << endl;

Ta có thể hiểu rằng đối tượng a đang gọi thực hiện phương thức operator> với đối số là đối tượng b. Các hàm nạp chồng những toán tử so sánh khác cũng được định nghĩa tương tự.

3.4. Toán tử gán (Assignment operator)

Trong quá trình thực hiện phép gán giữa hai đối tượng, toán tử gán (=) chỉ đơn giản là sao chép dữ liệu đối tượng nguồn (đối tượng bên phải toán tử gán) sang đối tượng đích (đối tượng bên trái toán tử gán).
Đặc biệt, hàm đa năng hóa toán tử gán chỉ có thể được định nghĩa dưới dạng phương thức của lớp.
Hàm đa năng hóa toán tử gán của lớp PhanSo được định nghĩa như sau:

PhanSo& PhanSo::operator=(const PhanSo& x){
tu = x.tu;
mau = x.mau;
return *this;
}

Thông thường, giá trị trả về của toán tử gán sau khi được nạp chồng là tham chiếu tới đối tượng bên trái toán tử =. Điều này phù hợp với chức năng truyền thống của toán tử gán mặc định. Dòng lệnh return *this giúp trả về đối tượng đang gọi thực hiện phương thức (được trỏ đến bởi con trỏ this). Vì vậy, ta có thể thực hiện chuỗi phép gán sau:

PhanSo a, b, c(1, 2)
a = b = c; // a.operator=(b.operator=(c))

Tương tự như phương thức thiết lập sao chép3 , hàm đa năng hóa toán tử gán cũng nhận vào một tham chiếu hằng để đảm bảo đối số truyền vào không thể bị sửa đổi một cách vô ý, cũng như đảm bảo rằng có thể truyền vào phương thức một đối số hằng.

Nếu không làm gì, chương trình sẽ tự nạp chồng cho lớp một toán tử gán, tuy nhiên ta cần phải tự nạp chồng toán tử gán với những lớp có thuộc tính đặc biệt như con trỏ.

3.5. Toán tử số học, gán kết hợp (Compound-assignment operator)

Là sự kết hợp giữa toán tử số học và toán tử gán.
Ví dụ:

PhanSo& PhanSo::operator+=(const PhanSo& x){
tu = tu * x.mau + mau * x.tu;
mau = mau * x.mau;
return *this;
}

3.6 Toán tử tăng một, giảm một (Increment, decrement operator)

     Loại toán tử này có hai phiên bản là tiền tố và hậu tố. Cả hai đều tăng giá trị của đối tượng lên 1, tuy nhiên phiên bản tiền tố sẽ trả về giá trị của đối tượng sau khi đã tăng thêm 1, còn phiên bản hậu tố thì trả về giá trị của đối tượng trước khi tăng thêm 1. Cả hai đều được nạp chồng bằng phương thức của lớp.

3.6.1 Phiên bản tiền tố (++a)

     Vì là toán tử 1 ngôi và toán hạng duy nhất của nó là đối tượng đang gọi thực hiện phương thức nên hàm nạp chồng sẽ không có tham số đầu vào:

PhanSo& PhanSo::operator++(){
*this += PhanSo(1);
return *this;
}

Con trỏ this ở đây giữ dịa chỉ của đối tượng đang gọi phương thức, return *this tức là trả về đối tượng đang gọi thực hiện phương thức. Kiểu dữ liệu trả về là tham chiếu chỉ để tránh phải thực hiện sao chép nhiều lần.

3.6.2 Phiên bản hậu tố (a++)

     Để chương trình có thể phân biệt được 2 kiểu operator++ khác nhau thì phiên bản hậu tố sẽ có thêm 1 tham số đầu vào giả:

PhanSo PhanSo::operator++(int){
PhanSo ret = *this;
++ *this; // sử dụng lại phiên bản tiền tố đã định nghĩa
return ret;
}

     Lúc lập trình ta chỉ cần ghi các câu lệnh như bình thường, chương trình sẽ tự biết là nên gọi phương thức nào:

PhanSo a(2,3);
++a; // gọi phiên bản tiền tố
a++; // gọi phiên bản hậu tố

Kiểu dữ liệu trả về của phiên bản hậu tố là kiểu PhanSo bình thường bởi vì không nên trả về một tham chiếu hoặc một con trỏ tới một biến cục bộ của hàm4 (ở đây là ret).

Footnotes

  1. Một đối tượng thuộc lớp istream hoặc ostream thì không sao chép được nên phải sử dụng tham chiếu (xem lại về tham chiếu ở chương 2.7.3 phần phương thức thiết lập sao chép).

  2. Xem lại về tham chiếu hằng ở chương 2.7.3 phần phương thức thiết lập sao chép

  3. Xem lại về phương thức thiết lập sao chép ở chương 2.7.3

  4. Các bạn tự tìm hiểu thêm ha chứ ghi vào thì bị rối và loãng nội dung <3 : https://www.educative.io/answers/resolving-the-function-returns-address-of-local-variable-error