+4

Bài 13: Trừu tượng hóa dữ liệu (phần 2) - Cài đặt Struct nâng cao

Đây là phần 2 của bài học về Cấu trúc (Struct) trong C++. Để hiểu rõ bài viết này, mời bạn đọc hãy xem lại bài viết phần 1 tại đây.

I. Hàm khởi tạo (Constructor)

1. Hàm khởi tạo mặc định (Default constructor)

Khi tạo ra một biến kiểu struct, ta thường đơn giản khai báo bằng cú pháp {Tên_cấu_trúc} {Tên_biến_cấu_trúc}; (đã học ở bài trước). Tuy nhiên, trên thực tế khi ta tạo một biến cấu trúc, thì chương trình C++ đã ngầm gọi một hàm đặc biệt, có vai trò khởi tạo giá trị ban đầu cho các biến thành viên của struct, hàm này gọi là Hàm khởi tạo (Constructor).

Lấy ví dụ, ta có một struct BookBook như sau:

struct Book
{
    int id;
    string book_name, author;
};

Nếu ta khai báo một biến kiểu Book,Book, theo cú pháp cơ bản ta sẽ viết:

Book book1;

Đối với cách khai báo này, một biến book1book1 được tạo ra mà không có các giá trị khởi tạo cho các trường của struct BookBook. Tuy nhiên, khi ta tạo ra struct Book,Book, thì phiên bản đầy đủ của nó sẽ là như sau:

struct Book
{
    int id;
    string book_name, author;
    
    Book()
    {
        id = 0;
        book_name = author = ""; // Chuỗi rỗng.
    }
};

Khi tạo một biến kiểu struct mà không cung cấp giá trị khởi tạo cho các trường, một hàm khởi tạo mặc định sẽ được gọi, hàm này có vai trò khởi tạo các trường của biến tạo ra bằng giá trị mặc định của chúng (nếu có). Trong trường hợp này, hàm Book() là hàm khởi tạo mặc định của struct sẽ được gọi, nhưng ta không cần viết hàm này khi khai báo struct vì nó được gọi mặc định. Vì thế, khi ta thử in ra giá trị của các trường trong biến book1,book1, ta vẫn sẽ thu được kết quả id=0id = 0book_name=author=book\_name = author = xâu rỗng.

2. Hàm khởi tạo có tham số (Parameterized constructor)

Khi muốn tạo ra các biến struct đồng thời truyền vào dữ liệu luôn cho các trường thành viên, ta có thể sử dụng các hàm khởi tạo có tham số. Cú pháp hàm khởi tạo này như sau:

{Tên_struct}({Danh_sách_tham_số_truyền_vào})
{
    {Khởi_tạo_các_trường_bằng_tham_số_tương_ứng};    
}

Khi đã sử dụng hàm khởi tạo có tham số, các bạn nên khai báo thêm một hàm khởi tạo mặc định rỗng để tránh gặp các lỗi phát sinh khi khai báo biến struct. Hàm khởi tạo rỗng này giúp ta vẫn có thể khai báo được một biến mà không truyền vào tham số nào cả.

Chẳng hạn, nếu ta muốn một hàm khởi tạo trước các giá trị ididbook_name\text{book\_name} cho struct Book,Book, cú pháp như sau:

struct Book
{
    int id;
    string book_name, author;
    
    Book() {}
    
    Book(int _id, string _book_name)
    {
        id = _id;
        book_name = _book_name;
    }
};

Và khi khởi tạo một biến struct, ta sẽ đồng thời truyền vào cho nó các giá trị tương ứng với hàm khởi tạo đã viết (vẫn có thể không dùng tham số, vì hàm khởi tạo mặc định vẫn tồn tại):

Book book1; // Sử dụng hàm khởi tạo mặc định.
Book book2(1, "Đất rừng phương Nam"); // Sử dụng hàm khởi tạo có tham số.

Lúc này, book1book1 sẽ có id=0id = 0 và các trường book_name,authorbook\_name, author là xâu rỗng; còn book2book2 sẽ có id=1id = 1 và trường book_name=book\_name = Đất rừng phương Nam.

Trong cùng một struct có thể tồn tại nhiều hàm khởi tạo có tham số với số lượng tham số khác nhau, phục vụ cho các mục đích khởi tạo khác nhau. Với struct BookBook nói trên, ta có thể thêm một vài hàm khởi tạo khác như sau:

struct Book
{
    int id;
    string book_name, author;
    
    Book(int _id)
    {
        id = _id;    
    }
    
    Book(int _id, string _book_name)
    {
        id = _id;
        book_name = _book_name;
    }
    
    Book(int _id, string _book_name, string _author)
    {
        id = _id;
        book_name = _book_name;
        author = _author;
    }
};

Lúc này, ta có thể khởi tạo một biến kiểu BookBook theo 4 cách khác nhau:

Book book1; // Constructor mặc định.
Book book2(1); // Constructor số 1.
Book book3(2, "Đất rừng phương Nam"); // Constructor số 2.
Book book4(3, "Lều chõng", "Ngô Tất Tố"); // Constructor số 3.

Tuy nhiên, kĩ thuật này không cần thiết lắm trong các bài toán của lập trình thi đấu. Thay vào đó, ta có thể viết trực tiếp một hàm thành viên để nhập dữ liệu trong thân struct, các bạn sẽ hiểu rõ hơn qua phần II.

II. Các hàm thành viên của Struct

Không chỉ đơn giản là lưu trữ một bản ghi gồm nhiều dữ liệu khác nhau, struct trong C++ còn có sức mạnh vô cùng to lớn trong việc cài đặt các cấu trúc dữ liệu tích hợp cùng các thao tác của nó.

Hãy tưởng tượng các bạn có một cấu trúc thể hiện đối tượng "Con mèo". Một con mèo sẽ có các thông tin: Tên, tuổi, giống loài; và ta có thể thực hiện các hành động sau với một con mèo: Kêu, Đưa ra tên, Đưa ra giống loài và Tính giá tiền phụ thuộc vào giống loài.

Tất cả những thông tin và hành động trên có thể được đóng gói lại trong một struct thể hiện con mèo như sau:

struct Cat
{
    string name;
    int age;
    string type;
    
    Cat(string _name, int _age, string _type)
    {
        name = _name;
        age = _age;
        type = _type;
    }
    
    void shout()
    {
        cout << "Meow...meow" << endl;
    }
    
    void get_name()
    {
        cout << name << endl;    
    }
    
    void get_type()
    {
        cout << type << endl;
    }
    
    int get_cost()
    {
        if (type == "Short-haired")
            return 10000;
        else if (type == "Long-haired")
            return 20000;
        else if (type == "Short-legged")
            return 30000;
    }
};

Cách lập trình với struct như trên là một phần của kĩ thuật Lập trình hướng đối tượng (OOPs), thường sẽ được đào tạo ở bậc Đại học. Trong kĩ thuật này, ta sẽ tạo ra một struct đi kèm với các dữ liệu và các hành động sẽ thao tác với dữ liệu đó (dưới dạng các hàm thành viên của struct). Ở đây chúng ta chỉ học một phần rất nhỏ của kĩ thuật này để giúp cho việc sử dụng struct được chuyên nghiệp và có nhiều ích lợi hơn.

Sau khi tạo ra một struct hoàn chỉnh như trên, giờ ta sẽ tạo ra một biến thể hiện một con mèo:

Cat cat1("Tom", "2", "Short-haired");

Các hàm thành viên của một struct cũng được truy cập bằng toán tử . giống như các trường thông tin. Mỗi khi một biến kiểu CatCat được tạo ra, nó sẽ sở hữu toàn bộ các thông tin cũng như các hàm được cài đặt ở struct CatCat:

cat1.shout();
cat1.get_name();
cat1.get_type();
cout << cat1.get_cost() << endl; 

Kết quả chạy đoạn chương trình:

Meow...meow
Tom
Short-haired
10000

Như vậy, mỗi khi chương trình phát sinh lỗi liên quan tới các biến thuộc kiểu Cat,Cat, ta sẽ chỉ tập trung sửa lỗi ở bên trong struct CatCat mà thôi. Điều này giúp chương trình dễ dàng bảo trì, sửa lỗi, bởi vì khi cần thay đổi một phần của chương trình, ta chỉ cần tập trung sửa đổi logic của những struct liên quan tới phần đó mà không cần quan tâm tới các struct khác.

III. Bài tập áp dụng

1. Thao tác phân số

Đề bài

Cho hai phân số ab\frac{a}{b}cd\frac{c}{d}.

Yêu cầu: Hãy in ra tổng, hiệu, tích và thương của hai phân số trên?

Input:

  • Một dòng duy nhất chứa bốn số nguyên a,b,c,da, b, c, d.

Ràng buộc:

  • 1a,b,c,d1001 \le a, b, c, d \le 100.

Output:

  • In ra trên bốn dòng, mỗi dòng là một phân số lần lượt thể hiện tổng - hiệu - tích - thương của hai phân số đã cho. Các kết quả không cần phải tối giản.

Sample Input:

3 5
4 9

Sample Output:

47 45
7 45
12 45
27 20

Ý tưởng

Đây là một bài toán rất đơn giản, chúng ta hoàn toàn có thể khai báo một struct thể hiện phân số, rồi viết các hàm đơn lẻ để thực hiện từng phép toán theo yêu cầu đề bài.

Tuy nhiên, việc lập trình sẽ trở nên khoa học hơn, nếu như ta "đính kèm" các phép toán vào trong struct thể hiện phân số, như vậy struct này sẽ bao gồm luôn cả các thao tác cộng, trừ, nhân và chia hai phân số. Thậm chí cả thao tác nhập xuất dữ liệu cho phân số cũng nên được cài đặt dưới dạng một hàm thành viên trong struct.

Trong code mẫu bên dưới, hai hàm nhập xuất sẽ được cài đặt luôn trong struct, vì thế nên hàm khởi tạo là không cần thiết. Khi nhập dữ liệu cho phân số, ta sẽ gọi hàm input thay vì nhập dữ liệu ở ngoài rồi khai báo phân số thông qua hàm khởi tạo.

Các hàm tính toán sẽ được cài đặt một cách hơi đặc biệt:

  • Do tổng, hiệu, tích và thương của hai phân số cũng lại là một phân số, nên các hàm thao tác sẽ có kiểu trả về chính là kiểu phân số (ở đây là struct FracFrac).
  • Trong các hàm này, tham số truyền vào lại là một phân số khác (biến otherother). Tức là khi gọi hàm cộng của phân số f1f_1 chẳng hạn, thì thực tế ta cần cộng f1f_1 với một phân số f2f_2. Vì vậy, các hàm này sẽ có tham số thể hiện rằng phép tính này được thực hiện giữa hai struct cùng kiểu FracFrac. Các tham số được truyền kiểu tham chiếu để đẩy nhanh tốc độ chương trình, và thêm từ khóa const để tránh bất kì sự thay đổi nào tác động lên tham số thực sự ở bên ngoài.
  • Kết quả của các hàm sẽ được lưu vào một phân số, nên ta lại khai báo một biến kiểu FracFrac để lưu kết quả, rồi trả ra chính biến đó cho hàm. Hoặc có thể trả về kết quả bằng cặp dấu {} để thể hiện kết quả có nhiều hơn 11 trường dữ liệu.

Các cài đặt cụ thể khác, các bạn hãy xem ở code mẫu để hiểu rõ hơn!

Code mẫu

#include <bits/stdc++.h>

using namespace std;

struct Frac
{
    int a, b;
    
    void input()
    {
        cin >> a >> b;    
    }
    
    void output()
    {
        cout << a << ' ' << b << endl;    
    }
    
    // Cộng phân số này với phân số other.
    Frac add(const Frac& other)
    {
        Frac sum;
        sum.a = a * other.b + b * other.a;
        sum.b = b * other.b;
        
        return sum;
        
        // Có thể viết ngắn: return {a * other.b + b * other.a, b * other.b};
    }
    
    // Trừ phân số này với phân số other.
    Frac subtract(const Frac& other)
    {
        Frac sub;
        sub.a = a * other.b - b * other.a;
        sub.b = b * other.b;
        
        return sub;
        
        // Có thể viết ngắn: return {a * other.b - b * other.a, b * other.b};
    }
    
    // Nhân phân số này với phân số other.
    Frac multiply(const Frac& other)
    {
        Frac mul;
        mul.a = a * other.a;
        mul.b = b * other.b;
        
        return mul;
        
        // Có thể viết ngắn: return {a * other.a, b * other.b};
    }
    
    // Chia phân số này với phân số other.
    Frac divide(const Frac& other)
    {
        Frac div;
        div.a = a * other.b;
        div.b = b * other.a;
        
        return div;
        
        // Có thể viết ngắn: return {a * other.b, b * other.a};
    }
};

int main()
{
    // Khai báo hai phân số f1, f2 đại diện cho a/b và c/d. Sau đó gọi các hàm. 
    Frac f1, f2;
    f1.input();
    f2.input();
    
    Frac sum = f1.add(f2);
    Frac sub = f1.subtract(f2);
    Frac mul = f1.multiply(f2);
    Frac div = f1.divide(f2);
    
    sum.output();
    sub.output();
    mul.output();
    div.output();
    
    return 0;
}

2. Xử lý tam giác

Đề bài

Cho một tam giác trong mặt phẳng tọa độ OxyOxy. Mỗi đỉnh của tam giác được thể hiện bằng hoành độ và tung độ.

Yêu cầu: Hãy tính diện tích tam giác, đồng thời cho biết tam giác này có dạng đều, vuông cân, cân, vuông hay thường?

Input:

  • Gồm ba dòng, mỗi dòng gồm hai số nguyên x,yx, y thể hiện tọa độ một đỉnh của tam giác.

Ràng buộc:

  • x,y100|x|, |y| \le 100.
  • Dữ liệu đảm bảo ba đỉnh cho trước tạo thành một tam giác.

Output:

  • Dòng đầu tiên in ra diện tích tam giác, làm tròn tới 22 chữ số sau dấu chấm thập phân.
  • Dòng thứ hai in ra dạng của tam giác là Deu, Vuong can, Can, Vuong hoặc Thuong tương ứng.

Sample Input:

1 2
5 5
5 -1

Sample Output:

12.00
Can

Ý tưởng

Trong bài toán này, có tới hai dạng dữ liệu có thể được trừu tượng hóa thành các struct: Điểm trên mặt phẳng tọa độ và Tam giác.

Đầu tiên, ta sẽ viết một struct PointPoint để thể hiện một điểm trên mặt phẳng tọa độ, đi kèm với các thao tác nhập dữ liệu và tính khoảng cách giữa hai điểm (bởi vì ta nhận thấy sẽ phải dùng tới công thức này trong khi làm việc với tam giác).

Tiếp đến, struct TriangleTriangle sẽ đại diện cho tam giác trong mặt phẳng tọa độ Oxy,Oxy, và nó sẽ có 33 thành viên là 33 điểm kiểu PointPoint. Ngoài ra, khoảng cách giữa các điểm này (chính là các cạnh tam giác) cũng sẽ phải sử dụng, nên ta sử dụng thêm các biến phụ là d1,d2,d3d_1, d_2, d_3 để lưu độ dài các cạnh tam giác. Giá trị của 33 biến này được khởi tạo ngay khi nhập dữ liệu các đỉnh xong.

Việc nhập dữ liệu cho các đỉnh tam giác thực tế là gọi lại hàm nhập dữ liệu điểm đủ 33 lần, vì vậy hàm input() trong struct TriangleTriangle sẽ gọi lại hàm input() trong struct PointPoint. Tương tự với ba biến d1,d2,d3d_1, d_2, d_3.

Để tính diện tính tam giác, ta sử dụng công thức Heron:

S=p×(pa)×(pb)×(pc)S = \sqrt{p \times (p - a) \times (p - b) \times (p - c)}

<center>

Với pp là nửa chu vi của tam giác

</center>

Cuối cùng, phần phức tạp nhất là kiểm tra loại tam giác. Ta biết rằng, để kiểm tra tính chất đều, cân hay vuông đều sẽ phải sử dụng thao tác so sánh bằng giữa các cạnh của tam giác. Tuy nhiên, do các giá trị d1,d2,d3d_1, d_2, d_3 đều là các số thực, nếu như so sánh trực tiếp chúng với nhau bằng toán tử == sẽ không chính xác do vấn đề sai số của số thực. Để giải quyết điều này, ta sẽ bình phương các giá trị d1,d2,d3d_1, d_2, d_3 lên rồi so sánh với nhau, như vậy sẽ tránh được vấn đề số thực sinh ra do hàm sqrt().

Code mẫu

#include <bits/stdc++.h>

using namespace std;

struct Point
{
    int x, y;

    void input()
    {
        cin >> x >> y;
    }

    double sqr(int n)
    {
        // Trả về n^2, nhân thêm với 1.0 để ép kiểu thành số thực.
        return n * n * 1.0;
    }

    double distance(const Point& other)
    {
        return sqrt(sqr(x - other.x) + sqr(y - other.y));
    }
};

struct Triangle
{
    Point a, b, c;
    double d1, d2, d3;

    void input()
    {
        a.input();
        b.input();
        c.input();

        d1 = a.distance(b);
        d2 = b.distance(c);
        d3 = c.distance(a);
    }

    double get_area()
    {
        double p = (d1 + d2 + d3) / 2;

        return sqrt(p * (p - d1) * (p - d2) * (p - d3));
    }

    string get_type()
    {
        // Bình phương d1, d2, d3 lên để loại bỏ phần thập phân.
        d1 = (int) (d1 * d1);
        d2 = (int) (d2 * d2);
        d3 = (int) (d3 * d3);

        if (d1 == d2 && d2 == d3)
            return "Deu";
        else if ((d1 == d2 || d2 == d3) && (d1 == d2 + d3 || d2 == d1 + d3 || d3 == d1 + d2))
            return "Vuong can";
        else if (d1 == d2 || d2 == d3 || d1 == d3)
            return "Can";
        else if (d1 == d2 + d3 || d2 == d1 + d3 || d3 == d1 + d2)
            return "Vuong";
        else
            return "Thuong";
    }
};

int main()
{
    Triangle t;
    t.input();

    cout << fixed << setprecision(2) << t.get_area() << endl;
    cout << t.get_type();

    return 0;
}

IV. Tài liệu tham khảo


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí