OOP - Định nghĩa class string

Hiện tại em có một bài tập về việc định nghĩa class String, em có search code trên Google để tham khảo, nhưng có vài đoạn em không hiểu lắm, mong mọi người có thể giải thích dùm em.

public:
            String(int); 
            String(const char*); 
            String(const String&); 

Ở phần khai báo hàm khởi tạo, tại sao lại có nhiều hàm khỏi tạo vậy @@ em nghĩ rằng mỗi class chỉ có một constructor thôi chứ ??? và các từ const được đặt vào đó với mục đích gì vậy.

 String::String(const char* x) 
{ 
    int l = 0; 
    while(x[l]!='\0') 
        l++; 
    len = l; 
    s = new char[len]; 
    for(int i=0; i<len; i++) 
        s[i] = x[i]; 
} 

 String::String(const String& x) 
{ 
    len = x.len; 
    s = new char[len]; 
    for (int i=0; i<len; i++) 
        s[i] = x.s[i]; 
} 

String::String(int len) 
{ 
    this->len = len; 
    s = new char[len]; 
} 

Đây là nội dung của 3 hàm dựng trên, cho em hỏi rằng trong hàm main khi đối tượng được gọi thì constructor nào trong 3 cái trên đây sẽ được gọi ạ.
Em cảm ơn mọi người đã dành thời gian giải đáp thắc mắc cho em

  1. Tại sao lại có nhiều constructor? -> Để tiện sử dụng.
  2. Các từ khóa const được đặt vào với mục đích gì? -> Để tránh việc người viết phần implement lỡ tay thay đổi biến truyền vào.
  3. Ý nghĩa các constructor? -> Từ trên xuống dưới, method đầu là tạo String từ 1 mảng các char, hay có thể hiểu là tạo string từ loại string cũ của c, c++. Method 2, tạo string từ 1 string khác. Method 3, tạo string rỗng với độ dài cho trước, nội dung sẽ được gán sau bởi người lập trình.
  4. Trong hàm main khi đối tượng được gọi thì constructor nào được gọi? -> Cái này tùy vào bạn, tùy vào loại dữ liệu truyền vào sẽ gọi đến constructor tương ứng
1 Like

Cái này để tương thích với chuỗi C cũ.

Cái này buộc phải có. Đây là nền tảng cơ bản cho các phương thức sau, như nối chuỗi (return từ op+) hay gán.

1 Like

Các từ khóa const được đặt vào với mục đích gì? -> Để tránh việc người viết phần implement lỡ tay thay đổi biến truyền vào.

Đầu tiên mình rất cảm ơn bạn vì đã trả lời chi tiết từng câu hỏi của mình.
Mình nghĩ nếu muốn ngăn chặn việc lỡ làm thay đổi biến. mình có thể truy câp dưới dạng tham biến thì sau khi thực hiện xong hàm thì giá trị vẫn không thay đổi. Vậy tại sao mình còn dùng const trong trường hợp này.
Hay ý của bạn về việc ngăn implement thay đổi biến truyền vào là ngăn chặn trong chính hàm đó luôn.
Mong bạn giải thích rõ hơn cho mình phần đó.

Ngăn trong chính hàm đó thôi, thường sẽ tránh cho lập trình viên vô ý làm thay đổi dữ liệu, 1 cách để tránh bug tiềm tàng thôi

1 Like

Đầu tiên mình sẽ giải thích tại sao nên dùng từ khóa const trước tham số. Phương thức mà có tham số dưới dạng tham biến, bản chất là tham số đó sẽ copy giá trị của đối số và thao tác với bản copy đó chứ không thao tác trực tiếp với đối số. Và quá trình đó có 1 vấn đề là đã có đối tượng được tạo ra ,cái bản copy đó đã được tạo ra để thực hiện phương thức => gây lãng phí tài nguyên không cần thiết. Vậy cho nên ta có thể dùng tham số kiểu tham chiếu & để tránh việc tạo ra đối tượng mới. Nhưng tham chiếu & lại có 1 vấn đề là có thể sẽ thay đổi giá trị của đối số. Nhưng ở đây ta không hề muốn thay đổi giá trị của đối số như vậy. Vậy làm thế nào để khắc phục cả 2 vấn đề trên => hằng tham chiếu (const Class & obj)

Thực ra thì việc tạo ra đối tượng copy sẽ không tốn quá nhiều tài nguyên đâu nếu kiểu dữ liệu của tham số là kiểu dữ liệu có sẵn như int, float, double. Việc dùng const & so với tham biến chỉ khác biệt khi đó là đối tượng thuộc lớp được ta định nghĩa chẳng hạn như đối tượng thuộc lớp PhanSo, SoPhuc,… vì những kiểu dữ liệu như vậy mới phức tạp chứ int, float, double nhẹ hều à :stuck_out_tongue: Tuy nhiên, giả sử ta muốn chắc chắn khi implement phương thức ta không làm thay đổi tham số đó, ta có thể dùng const & luôn thay vì tham biến để tránh rủi ro. Giả sử nếu ta có lỡ tay thay đổi cái tham số mà ta không muốn thay đổi thì compiler sẽ báo lỗi.

Vậy tóm lại, khi mà phương thức đó có ý định thay đổi giá trị của đối số => dùng tham chiếu &
Khi mà phương thức đó không có ý định thay đổi giá trị của đối số => dùng hằng tham chiếu const &
Khi mà phương thức đó muốn thay đổi giá trị của tham số nhưng không ảnh hưởng đến giá trị của đối số => dùng tham biến. Chẳng hạn hàm tìm ước chung lớn nhất của mình:

int Ucln(int a, int b)
{
	a = abs(a), b = abs(b);
	while (a * b > 0) {
		if (a > b)
			a -= b;
		else
			b -= a;
	}
	return a + b;
}

Còn 2 vấn đề mình muốn nói tiếp đó là từ khóa const sau phương thức và copy constructor, nếu bạn muốn mình sẽ nói tiếp :3

2 Likes

Dĩ nhiên rồi. Mong bạn có thể nói tiếp 2 phần còn lại. Cảm ơn bạn.

Vấn đề tiếp theo đó là const sau phương thức. Ví dụ cái operator+ hai số phức mình copy trong bài tập về nhà ra luôn đi ~~
> SoPhuc SoPhuc::operator+(const SoPhuc & rhs) const
> {
> SoPhuc result;
> result.thuc = this->thuc + rhs.thuc;
> result.ao = this->ao + rhs.ao;
> return result;
> }
> cái const trong (const SoPhuc & rhs) chắc bạn đã hiểu rồi. Vậy còn, const cuối dòng là gì?
> Đơn giản là nó không cho phép ta thay đổi giá trị của thành phần dữ liệu của đối tượng this. ví dụ giả sử có dòng lệnh this->thuc++ hoặc this->ao = rhs.ao chẳng hạn thì compiler sẽ báo lỗi ngay => giảm rủi rõ. Nhỡ như thay vì result.thuc = ta gõ nhầm chỉ còn thuc = thì compiler sẽ báo lỗi. Cái này thì dễ rồi, nhưng nó còn không cho ta truy cập đến các phương thức khác ngoại trừ phương thức const. Mấy phương thức const được chơi với nhau. Tức là sao?? Chẳng hạn this->SetThuc(1) compiler sẽ báo lỗi vì ta đã truy cập đến phương thức khác (phương thức SetThuc). Nhưng, giả sử như ta có nhu cầu muốn truy cập đến phương thức khác thì sao. Chẳng hạn ta đang cần viết 1 phương thức cộng một ngày (1/1/2018 chẳng hạn) với một số ngày (100 ngày chẳng hạn). Để viết phương thức đó ta cần truy cập đến các phương thức kiểm tra năm nhuận, rồi tính xem số ngày tối đa của một tháng là bao nhiêu chẳng hạn. Vậy thì làm sao, đơn giản là các phương thức int SoNgayToiDaCuaThang() với bool NamNhuan() cũng thêm từ khóa const vào cuối phương thứ là xong thành int TimSoNgayToiDaCuaThang() const chẳng hạn . Như mình đã nói const được chơi với nhau.
> Tóm lại, phương thức nào ta implement với ý định không làm thay đổi thành phần dữ liệu cứ thêm từ khóa const ở cuối phương thức là xong, nó sẽ tự động phù hợp. Còn nếu mà ta code có một vài phương thức không làm thay đổi dữ liệu nhưng không có const, một vài còn lại có const thì sẽ dẫn đến lỗi, nên đã thêm tốt nhất thêm đồng bộ là xong.

4 Likes

Còn cái cuối cùng nữa là copy constructor, bạn chỉ cần nhớ copy constructor dùng để tránh hiện tượng dùng chung vùng nhớ và một vùng nhớ bị xóa 2 lần. Và copy constructor chỉ cần thiết khi lớp định nghĩa có thành phần dữ liệu động. Thêm nữa là operator= (assignment operator). Khi nào có thành phần dữ liệu động, để an toàn cứ auto viết thêm operator= với copy constructor.


Đi vào vấn đề…
Đầu tiên mình muốn làm rõ cái này xí, giả sử ta khai báo:

  1. PhanSo a;

  2. PhanSo b(a); hoặc PhanSo b = a;

  3. PhanSo b; b = a;
    Ở dòng 1. Đối tượng a thuộc kiểu kiểu dữ liệu PhanSo đã được tạo ra, được tạo ra mà không có đối số, tức là nó đã được tạo ra bằng constructor mặc định, không có gì để bàn cãi.
    Ở dòng 2 thì 2 cái khai báo đó là như nhau, đối tượng b cũng thuộc kiểu dữ liệu PhanSo được tạo, và nó gán bằng a. Đúng không? Thực ra đó không phải là phép gán bằng (assignment operator). Đó là copy constructor. Đối tượng b được tạo ra bằng copy constructor với đối số truyền vào là a. Phép gán bằng được thực hiện thực ra là ở dòng 3. dòng 2 và dòng 3 thực ra là khác nhau. Bạn có thể chạy chương trình dưới đây để kiểm chứng.

    struct DayNhauHoc
    {
    DayNhauHoc() { }
    DayNhauHoc(const DayNhauHoc & dnh) { cout << “copy constructor” << endl; }
    void operator=(const DayNhauHoc & dnh) { cout << “assignment operator” << endl; }
    };
    int main()
    {
    DayNhauHoc dnh1;
    DayNhauHoc dnh2 = dnh1;
    DayNhauHoc dnh3; dnh3 = dnh1;
    }
    Thực ra cái đoạn ở trên không liên quan đến việc tại sao ta lại cần một copy constructor đâu ><


Giờ mới vào vấn đề nè. Copy constructor là gì. Copy constructor nó cũng chỉ là một constructor như bao constructor khác, cũng chỉ để tạo đối tượng. Nhưng thay vì tạo một đối tượng không có giá trị thì nó sẽ tạo một đối tượng dựa vào một đối tượng đã tồn tại trước đó vậy thôi. Khi ta không khai báo constructor, chương trình tự tạo cho ta constructor mặc định với đ có gì trong đó :)), Ngoài ra, chương trình sẽ luôn tự tạo cho ta thêm constructor copy nữa. Và copy constructor nó làm cái chuyện đó là sao chép tương ứng từng thành phần dữ liệu một của đối tượng đối số truyền vào vào đối tượng *this sắp được tạo ra đó. Oke, trong đa số trường hợp thì constructor copy của hệ thống tự tạo đó là ổn. Đơn giản là copy dữ liệu thôi mà. Tuy nhiên, khi lớp định nghĩa có thành phần dữ liệu động, sẽ nảy sinh vấn đề vùng nhớ bị xóa 2 lần. Tức là sao?
Giả sử ta có một lớp đa thức với các thành phần dữ liệu là một mảng động dùng để chứa hệ số như bên dưới, có phương thức cộng hai đa thức:

class DaThuc
{
	float *heso;
	int bac;
public:
	DaThuc Cong(const DaThuc &daThuc2) const
	{
		DaThuc result;
		// Cộng hai đa thức this và daThuc2 lại thành result;
		return result;
	}
};

Khi return result; tức là đã có gọi copy constructor. Tức là sao? Ví dụ

DaThuc a, b, c;
a = b.Cong©;

Cái dòng 2, đối tượng b sẽ gọi hàm Cong với đối số là c. Trong phương thức này, để tính ra kết quả ta cần tạo ra một đối tượng result, cái này thì cũng không có gì để nói. Sau khi tính toán xong, vấn đề là nó sẽ return result để trả về giá trị cho cái b.Cong(c). Ở đây, bản chất b.Cong(c) cũng là một đối tượng mới được tạo ra bằng copy constructor, copy của cái thằng result. Sau đó cái đối tượng b.Cong(c) đó lại gán cho đối tượng a bằng operator=. Đó là những gì diễn ra ở 2 hàm trên. Đó là lý do tại sao ta cần copy constructor và operator=. Nhưng như mình đã nói ở trên chương trình luôn tự tạo 2 cái đó cho ta, vậy copy constructor mặc định của hệ thống có vấn đề gì? Vấn đề ở đây là nó copy toàn bộ dữ liệu của đối tượng đối số vào đối tượng được khởi tạo. Giả sử DaThuc a(b);. Nó sẽ copy toàn bộ thành phần dữ liệu của b vào a, trong đó có con trỏ int *heso. Con trỏ đó có gì, có địa chỉ của mảng hệ số, tức là 2 đối tượng đó dùng chung một mảng hệ số vì cùng trỏ tới cùng một mảng hệ số mà. Khi hệ số của đối tượng a thay đổi thì của b cũng thay đổi và ngược lại. Trong khi chúng ta chỉ muốn copy của thằng b vào thằng a cái mảng đó rồi sau đó đường ai nấy đi, dùng riêng. Và, ““giả sử”” ta có câu lệnh delete[] heso trong destructor. Khi đối tượng a hết phạm vi hoạt động, mảng hệ số được giải phóng => của b cũng bị giải phóng => chương trình gặp lỗi. Đó là lý do ta cần viết riêng copy constructor sao cho đối tượng a được tạo ra với một mảng heso khác, chỉ copy giá trị của từng phần tử thuộc mảng từ đối tượng b thôi chứ không dùng luôn mảng đó, (chỉ được copy thành cái khác rồi dùng chứ không được dùng chung). Tương tự ta cần thêm cái operator=
Dài quá… Hy vọng bạn hiểu :3

3 Likes

Tuyệt, mình cảm ơn bạn rất nhiều luôn. Bạn trả lời cực kỳ chi tiết và hầu như trả lời hầu hết những thắc mắc cuả mình về đoạn code trên luôn, nhất là phần copy constructor. Mình mới học, còn nhiều điều mơ hồ lắm, bạn đã giúp mình nhiều lắm đó ^^

1 Like

Bài này nên được hoàn thiện để thành một bài riêng :smiley:

3 Likes

Chào mọi người, hôm bữa mình có viết 3 cái comments, sau đó mình mới nhận ra thực ra mình chưa biết cách implement opeartor= đúng (khi có thành phần dữ liệu động). Mình đã vô tình biết thêm một số khái niệm cực hay như the rule of zero, the rule of three (and a half), the rule of four(and a half), the rule of five,… rvalue, lvalue,… copy and swap idiom. Đó là những thứ thực sự hay, nếu bạn nào hứng thú thì tìm hiểu nha. Có thể vào bài viết này của stackoverflow https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom

http://codepad.org/S3UcFYYu Còn đây là code mình implement ví dụ một class mảng số nguyên. Mọi người có thể tham khảo. Nếu ai không hiểu chỗ nào thì có thể nói bởi vì mình biết nhìn nó khá là kì quặc, nhưng thực ra (theo mình biết hiện tại) thì đó mới là implement tốt nhất. Nhìn có vẻ khó hiểu, đúng vậy mình đã suy ngẫm rất nhiều mới hiểu những thứ đó nhưng mình thấy nó lại khá quan trọng mà dường như trên trường không hề dạy nên để ở đây cho mọi người cùng tìm hiểu.
Thank you all for reading.

ĐÍnh chính là thực ra lúc viết 3 comments đó mình có hiểu sai về cách hoạt động của return vì nó còn liên quan đến rvalue và lvalue… Hiểu sai là hiểu chưa đủ chứ không phải là sai hoàn toàn, rất may là những gì mình viết ở bài trên thì không nằm trong trường hợp đặt biệt nên không có gì sai sót.
#Càng này mình càng nhận ra những thứ mình tưởng chừng như đã nắm rõ lại hóa ra mình thực sự chưa hiểu ><

1 Like

ehehe hack não tí nữa, đọc thêm guideline :joy: https://github.com/isocpp/CppCoreGuidelines/blob/master/CppCoreGuidelines.md#cctor-constructors-assignments-and-destructors:

ở ví dụ Array này thì cần thêm explicit vô cái ctor Array(size_t size) (C.46)

hàm nào ko ném ngoại lệ thì thêm keyword noexcept vô, ví du dtor, swap, move ctor (F.6)

nên xài C++11 in-class member initializers cho default ctor (C.45, C.48)

class Array
{
public:
    //Array() = default; //với ví dụ này ko có cũng được
    explicit Array(size_t size) : a{size ? new int[size] : nullptr}, size{size} {}
private:
    int* a{nullptr};  //gán giá trị mặc định ở đây luôn,
    size_t size{0};
};

cũng ko nhất thiết phải khai báo size trước a đâu, chỉ cần quẳng a lên trước size là ok

đọc thêm cái ví dụ ở C.60, đừng viết vội operator= là (Array other) vì lỡ 2 array có cùng size hoặc this có size lớn hơn thì viết kiểu này vô tình construct dư cái other này :joy:, nhưng viết kiểu trong ví dụ C.60 kia dài dòng quá

p/s: cứ quăng code lên diễn đàn, đừng chỉ dẫn link tới trang khác chứa code mà ko quăng code lên đây. vì lỡ trang đó chết trước daynhauhoc thì thành ra mấy người tới sau ko đọc đc code đó.

2 Likes

Thank you bro, hay quá.
Em vẫn còn thắc mắc xíu.
Ở chỗ operator=(Array other):

    // ... copy sz elements from *a.elem to elem ...
    if (a.sz < sz) {
        // ... destroy the surplus elements in *this* and adjust size ...
    }
    return *this;

Cho em hỏi là đoạn này “destroy the surplus elements” thì làm sao destroy được? Ví dụ a của this có 10000 số nguyên, a của other có 5000 số nguyên, ghi 5000 số nguyên của other vào của this. Rồi làm sao nữa??

Nếu không cần “quá tối ưu” như trên thì operator=(Array other) là đủ gộp copy-assignment với move-assignment chưa? Vì em nghĩ đối số là lvalue thì cái other sẽ tự động gọi copy-constructor. Nếu đối số là rvalue thì other sẽ tự động gọi move-constructor nên hiệu quả sẽ vẫn sẽ như khi biết hai hàm tách biệt (không tính việc size *this lớn hơn size other… phức tạp) ?

trước C++17: xài std::allocator :confounded:
sau C++17: xài std::destroy :dizzy_face:

đủ rồi

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