Cũng giống như các ngôn ngữ lập trình khác, C++ cũng hỗ trợ một số kiểu dữ liệu cơ bản (ví dụ: char, int, long, float, double, v.v…) thường là đủ để giải quyết các vấn đề tương đối đơn giản, nhưng là chưa đủ hoặc khó để có thể giải quyết được các vấn đề phức tạp, những bài toán thực tế được lấy từ chính cuộc sống hiện thực. C++ còn sở hữu một tính năng hữu ích hơn nữa, đó là khả năng cho phép lập trình viên tự định nghĩa ra kiểu dữ liệu của riêng mình, sao cho phù hợp với việc giải quyết các bài toán phức tạp. Bạn đã thấy cách mà kiểu dữ liệu struct và kiểu dữ liệu enum được sử dụng để tạo ra những kiểu dữ liệu tùy chỉnh.

Dưới đây là một vì dụ về việc sử dụng struct để xây dựng một kiểu dữ liệu date (ngày) tùy chỉnh:

struct DateStruct
{
    int year;
    int month;
    int day;
};

Các kiểu dữ liệu enum và các kiểu dữ liệu struct chỉ gồm dữ liệu (data-only structs là những struct chỉ chứa các biến) sẽ đại diện cho thế giới lập trình không hướng đối tượng truyền thống, vì chúng chỉ có thể chứa dữ liệu. Trong C++ 11, chúng ta có thể tạo ra và khởi tạo một cái struct chỉ gồm dữ liệu (data-only struct) này như sau:

DateStruct today { 2020, 10, 14 }; // use uniform initialization

Bây giờ, nếu muốn in date ra màn hình, ta cần viết một hàm để làm điều này. Dưới đây là đoạn chương trình đầy đủ:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
 
struct DateStruct
{
    int year;
    int month;
    int day;
};
 
void print(const DateStruct &date)
{
    std::cout << date.year << "/" << date.month << "/" << date.day;
}
 
int main()
{
    DateStruct today { 2020, 10, 14 }; // use uniform initialization
 
    today.day = 16; // use member selection operator to select a member of the struct
    print(today);
 
    return 0;
}

Kết quả in ra là:

2020/10/16

1. Class

Trong thế giới của lập trình hướng đối tượng, chúng ta thường muốn kiểu dữ liệu của mình không chỉ chứa được các dữ liệu, mà còn có thể cung cấp các hàm làm việc với chính các dữ liệu này. Trong C++, điều này thường được thực hiện thông qua từ khóa class. Sử dụng từ khóa class sẽ tạo ra một kiểu dữ liệu mới do người dùng định nghĩa, được gọi là một class (một lớp).

Trong C++, các class và struct về cơ bản là giống nhau. Trong thực tế, struct và class thực sự giống hệt nhau.

struct DateStruct
{
    int year;
    int month;
    int day;
};
 
class DateClass
{
public:
    int m_year;
    int m_month;
    int m_day;
};

Lưu ý rằng sự khác biệt đáng kể duy nhất trong đoạn code trên là từ khóa public bên trong class. Chúng ta sẽ nói về chức năng của từ khóa này trong bài học tiếp theo.

Giống như các khai báo struct bình thường khác, một khai báo class sẽ không được cấp phát bất kỳ bộ nhớ nào. Nó chỉ định nghĩa việc class sẽ trông như thế nào.

Cảnh báo

Cũng giống như với các structs, một trong những sai lầm dễ mắc phải nhất trong C++ là quên dấu chấm phẩy ở cuối phần khai báo class. Điều này sẽ gây ra lỗi trình biên dịch tại dòng code tiếp theo. Các trình biên dịch hiện đại như Visual Studio 2010 sẽ đưa ra những lời nhắc nếu bạn quên dấu chấm phẩy, nhưng các trình biên dịch cũ hơn hoặc đơn giản hơn sẽ không có những lời nhắc này, làm cho việc tìm kiếm nơi thực sự xảy ra lỗi trở nên khó khăn.

Các khai báo class (và struct) đóng vai trò như những khuôn mẫu (blueprint) – chúng mô tả đối tượng kết quả trông sẽ như thế nào, nhưng chúng không thực sự tạo ra đối tượng. Để thực sự tạo ra một đối tượng cho class, cần định nghĩa một biến thuộc kiểu class này:

DateClass today { 2020, 10, 14 }; // declare a variable of class DateClass

2. Các hàm thành viên bên trong class

Ngoài việc lưu giữ dữ liệu, các class (và struct) cũng có thể chứa các hàm! Các hàm được định nghĩa bên trong một class sẽ được gọi là các hàm thành viênmember functions (hoặc đôi khi là các phương thức – methods). Các hàm thành viên có thể được được nghĩa bên trong hoặc bên ngoài của khai báo class. Ở thời điểm hiện tại, để cho đơn giản, chúng ta sẽ chỉ xét đến trường hợp định nghĩa chúng bên trong class, trường hợp định nghĩa các hàm thành viên bên ngoài class sẽ được trình bày sau trong bài này.

Dưới đây là một class Date với hàm thành viên dùng để in ra một ngày:

class DateClass
{
public:
    int m_year;
    int m_month;
    int m_day;
 
    void print() // defines a member function named print()
    {
        std::cout << m_year << "/" << m_month << "/" << m_day;
    }
};

Cũng giống như các hàm thành viên của struct, các thành viên (bao gồm các biến và các hàm) của một class đều được truy cập bằng toán tử chọn thành viên (là dấu .):

#include <iostream>
 
class DateClass
{
public:
    int m_year;
    int m_month;
    int m_day;
 
    void print()
    {
        std::cout << m_year << "/" << m_month << "/" << m_day;
    }
};
 
int main()
{
    DateClass today { 2020, 10, 14 };
 
    today.m_day = 16; // use member selection operator to select a member variable of the class
    today.print(); // use member selection operator to call a member function of the class
 
    return 0;
}

Kết quả in ra là:

2020/10/16

Bạn có thể thấy rằng đoạn chương trình ví dụ này thì tương tự với phiên bản sử dụng struct mà chúng ta đã viết trước đó ở bên trên.

Tuy nhiên, vẫn có một vài sự khác biệt. Trong phiên bản DateStruct của hàm print() từ ví dụ bên trên, chúng ta cần truyền chính struct làm tham số đầu tiên cho hàm print(). Nếu không, hàm print() sẽ không biết được cái DateStruct mà chúng ta muốn sử dụng là cái gì. Sau đó, chúng ta phải tham chiếu tới tham số này bên trong hàm một cách rõ ràng.

Các hàm thành viên của class thì hoạt động hơi khác một chút: Tất cả các lời gọi hàm thành viên phải được liên kết với một đối tượng của class. Khi chúng ta gọi “today.print()”, chúng ta đang nói với trình biên dịch rằng, hãy gọi cái hàm thành viên print() được liên kết với đối tượng today.

Chúng ta cùng xem lại một lần nữa phần khai báo của hàm thành viên print():

 void print() // defines a member function named print()
    {
        std::cout << m_year << "/" << m_month << "/" << m_day;
    }

m_year, m_month_ và m_day thực sự có quan hệ với cái gì? Chúng có quan hệ với đối tượng được liên kết (được xác định bởi đối tượng gọi hàm).

Vì vậy, khi chúng ta gọi “today.print()”, trình biên dịch sẽ diễn giải m_day thành today.m_day, m_month thành today.m_month, và m_year thành today.m_year. Nếu chúng ta gọi “tomorrow.print()”, m_day sẽ được diễn giải thành tomorrow.m_day.

Theo cách này, đối tượng được liên kết về cơ bản sẽ được truyền một cách ngầm định cho hàm thành viên. Vì lý do này, nó còn thường được gọi là đối tượng ngầm (implicit object).

Chúng ta sẽ nói nhiều hơn và chi tiết hơn về cách thức hoạt động của quá trình truyền đối tượng ngầm (implicit object) trong một bài học sau của chương này.

Điểm mấu chốt ở đây là, đối với các hàm không phải là hàm thành viên, chúng ta phải truyền dữ liệu cho hàm để hàm làm việc với dữ liệu đó. Với các hàm thành viên, có thể cho rằng, chúng ta luôn có được một đối tượng ngầm (implicit object) của class, để làm việc.

Sử dụng tiền tố “m_” cho các biến thành viên giúp phân biệt các biến thành viên với các tham số hàm hoặc biến cục bộ bên trong các hàm thành viên. Điều này rất hữu ích vì nhiều lý do. Thứ nhất, khi thấy một phép gán cho một biến với tiền tố “m_”, chúng ta sẽ biết được rằng mình đang thay đổi trạng thái của thể hiện (instance) của class. Thứ hai, không giống như các tham số hàm hoặc biến cục bộ, những cái mà được khai báo bên trong hàm, các biến thành viên được khai báo bên trong phần định nghĩa của class. Do đó, nếu muốn biết một biến với tiền tố “m_” được khai báo như thế nào, hãy xem trong phần định nghĩa của class thay vì xem bên trong hàm.

Theo quy ước, tên của class nên bắt đầu bằng một chữ cái viết hoa.

Quy tắc: Hãy đặt tên cho các classes của bạn với ký tự đầu tiên là một chữ cái viết hoa.

Dưới đây là một ví dụ khác về class:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
#include <string>
 
class Employee
{
public:
    std::string m_name;
    int m_id;
    double m_wage;
 
    // Print employee information to the screen
    void print()
    {
        std::cout << "Name: " << m_name <<
                "  Id: " << m_id << 
                "  Wage: $" << m_wage << '\n'; 
    }
};
 
int main()
{
    // Declare two employees
    Employee alex { "Alex", 1, 25.00 };
    Employee joe { "Joe", 2, 22.25 };
 
    // Print out the employee information
    alex.print();
    joe.print();
 
    return 0;
}

Đoạn chương trình trên sẽ in ra:

Name: Alex  Id: 1  Wage: $25
Name: Joe  Id: 2  Wage: $22.25

Với các hàm không phải làm hàm thành viên (non-member functions) thì một hàm sẽ không thể gọi tới một hàm khác, được định nghĩa “bên dưới” nó (mà không sử dụng forward declaration – khái báo trước).

void x()
{
// You can't call y() from here unless the compiler has already seen a forward declaration for y()
}
 
void y()
{
}

Các hàm thành viên sẽ không bị ảnh hưởng bởi hạn chế trên (hạn chế về việc gọi hàm liên quan đến thứ tự các hàm được khai báo trong code):

class foo
{
public:
     void x() { y(); } // okay to call y() here, even though y() isn't defined until later in this class
     void y() { };
};

CHÚ Ý: Giải thích một chút về forward declaration – khai báo trước, forward declaration đề cập đến việc khai báo trước cú pháp hoặc ký hiệu của một định danh, biến, hàm, hoặc class, v.v… trước khi sử dụng nó (được thực hiện sau trong chương trình).

Đoạn code ví dụ về forward declaration:

void sum();   <= Đây chính là forward declaration
void sum() { <= Định nghĩa về hàm sum() đã được khai báo trước đó bằng forward declaration
           //Body
}

3. Các kiểu dữ liệu thành viên

Ngoài biến thành viên, hàm thành viên, class còn có thể sở hữu các kiểu dữ liệu thành viên (member types) hoặc các kiểu dữ liệu lồng (nested types) (bao gồm cả các type aliases – các bí danh dành cho kiểu dữ liệu). Trong ví dụ dưới đây, chúng ta sẽ tạo ra một máy tính có khả năng thay đổi nhanh chóng kiểu dữ liệu của số được sử dụng, khi cần:

/**
* Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
*
* @author cafedevn
* Contact: cafedevn@gmail.com
* Fanpage: https://www.facebook.com/cafedevn
* Instagram: https://instagram.com/cafedevn
* Twitter: https://twitter.com/CafedeVn
* Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

#include <iostream>
#include <vector>
 
class Calculator
{
public:
  using number_t = int; // this is a nested type alias
 
  std::vector<number_t> m_resultHistory{};
 
  number_t add(number_t a, number_t b)
  {
    auto result{ a + b };
 
    m_resultHistory.push_back(result);
 
    return result;
  }
};
 
int main()
{
  Calculator calculator{};
 
  std::cout << calculator.add(3, 4) << '\n'; // 7
  std::cout << calculator.add(99, 24) << '\n'; // 123
 
  for (Calculator::number_t result : calculator.m_resultHistory)
  {
    std::cout << result << '\n';
  }
 
  return 0;
}

Kết quả in ra:

7
123
7
123

Trong ngữ cảnh này, tên của class đóng vai trò như một namespace dành cho kiểu dữ liệu lồng (nested type). Từ bên trong class, chúng ta chỉ cần tham chiếu thẳng tới number_t. Từ bên ngoài class, chúng ta có thể truy cập tới kiểu dữ liệu này thông qua Calculator::number_t.

Khi xác định rằng kiểu dữ liệu int cơ bản không còn đáp ứng được như cầu hiện tại, và chúng ta muốn chuyển sang sử dụng kiểu double, chúng ta chỉ cần cập nhật bí danh của kiểu dữ liệu (type alias), thay vì phải thay đổi tất cả các từ khóa int thành double.

Các bí danh dành cho kiểu dữ liệu thành viên làm cho code trở nên dễ bảo trì hơn và giảm thiểu việc gõ phím của lập trình viên. Các Template classes (lớp khuôn mẫu) mà chúng ta sẽ tìm hiểu sau này, thường tận dụng sự tiện lợi của các bí danh dành cho kiểu dữ liệu thành viên (type alias members). Bạn đã từng nhìn thấy điều này trong các bài trước, ví dụ: std::vector::size_type, trong đó size_type là một bí danh (alias) dành cho một kiểu số nguyên không dấu (unsigned integer).

Các kiểu dữ liệu lồng (nested type) không thể được khai báo trước (bằng  cách sử dụng forward declaration). Nói chung, các kiểu dữ liệu lồng chỉ nên được sử dụng khi chúng được sử dụng riêng bên trong một class. Lưu ý rằng bởi vì class cũng là một kiểu dữ liệu, nên ta hoàn toàn có thể lồng các class bên trong các class khác – trong thực tế, điều này là không phổ biến và thường chỉ được thực hiện bởi các lập trình viên thâm niên.

4. Một lưu ý về struct trong C++

Trong C, struct chỉ có thể lưu giữ dữ liệu, mà không thể chứa các hàm thành viên liên quan. Trong C++, sau khi thiết kế ra các class (sử dụng từ khóa class), Bjarne Stroustrup đã dành một chút thởi gian để xem xét liệu rằng các struct (những cái mà được kế thừa từ C) có nên được cấp cho khả năng sở hữu các hàm thành viên hay không. Sau khi xem xét, ông ấy đã xác định rằng struct nên có được khả năng này, một phần nào đó để đạt được một tập quy tắc thống nhất dành cho cả hai (ý là class và struct). Vì vậy, mặc dù đã viết đoạn chương trình ở trên bằng cách sử dụng từ khóa class, chúng ta đồng thời cũng có thể sử dụng từ khóa keyword để làm được điều tương tự.

Rất nhiều developers (bao gồm cả bản thân tôi) cảm thấy rằng đây là một quyết định không chính xác, bởi vì nó có thể dẫn tới các giả định nguy hiểm. Ví dụ, thật công bằng khi cho rằng một class sẽ tự dọn dẹp chính bản thân nó sau khi được sử dụng xong (ví dụ: Một class được cấp phát bộ nhớ sẽ tự giải phóng phần bộ nhớ của mình trước khi bị hủy), nhưng thật không an toàn khi cho rằng/giả định rằng điều này cũng đúng đối với một struct. Do đó, chúng tôi khuyên bạn nên sử dụng từ khóa struct cho các cấu trúc dữ liệu chỉ bao gồm dữ liệu, và từ khóa class để định nghĩa các đối tượng mà yêu cầu cả dữ liệu và các hàm, được đóng gói cùng nhau.

Quy tắc: Hãy sử dụng từ khóa struct cho các cấu trúc dữ liệu chỉ bao gồm dữ liệu (data-only structures). Và sử dụng từ khóa class cho những đối tượng mà sở hữu cả dữ liệu và các hàm.

5. Bạn đã và đang sử dụng class mà không nhận ra điều đó

Trên thực tế, bản thân bộ thư viện standard của C++ đã bao gồm đầy đủ các class thiết yếu nhất, nhằm hỗ trợ và phục vụ lợi ích của người lập trình. std::string, std::vector, và std::array đều là các kiểu dữ liệu class! Vì vậy, khi bạn tạo ra một đối tượng dù thuộc về bất kỳ kiểu dữ liệu nào trong số trên, bạn  thật sự đang thể hiện (instatiating) một đối tượng của class. Và khi bạn gọi hàm bằng cách sử dụng các đối tượng này, bạn sẽ gọi đến các hàm thành viên của class mà đối tượng hiện tại thuộc về.

#include <string>
#include <array>
#include <vector>
#include <iostream>
 
int main()
{
    std::string s { "Hello, world!" }; // instantiate a string class object
    std::array<int, 3> a { 1, 2, 3 }; // instantiate an array class object
    std::vector<double> v { 1.1, 2.2, 3.3 }; // instantiate a vector class object
 
    std::cout << "length: " << s.length() << '\n'; // call a member function
 
    return 0;
}

Bài tập thực hành cơ bản về lớp(phần 1) trong C++

Bài tập thực hành cơ bản về lớp(phần 2) trong C++

Bài tập thực hành cơ bản về lớp(phần 3) trong C++

Bài tập thực hành cơ bản về lớp(phần 4) trong C++

6. Kết luận

Từ khóa class cho phép chúng ta tạo ra một kiểu dữ liệu tùy chỉnh trong C++ mà có thể chứa được cả các biến thành viên và các hàm thành viên. Các class đã tạo nên nền tảng dành cho lập trình hướng đối tượng, và chúng ta sẽ dành phần còn lại của chương này và nhiều chương khác nữa trong lương lai để khám phá về tất cả những gì chúng cung cấp.

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!