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

Thành viên tĩnh (static)

Thuộc tính bình thường thuộc về từng đối tượng (mỗi đối tượng một bản). Thuộc tính static thì chung cho cả lớp: chỉ tồn tại một ô nhớ duy nhất, mọi đối tượng cùng chia sẻ. Rất hợp để đếm số đối tượng đã tạo, giữ cấu hình chung...

Đặt vấn đề bằng một tình huống thực tế: ta cần biết trong chương trình đang có bao nhiêu đối tượng SinhVien còn sống. Nếu dùng một biến toàn cục ngoài lớp để đếm, ai cũng sửa được nó và không có gì buộc con số phải khớp với số đối tượng thật. Chỗ hợp lý để giữ con số này là bên trong lớp nhưng dùng chung cho cả lớp, không thuộc riêng đối tượng nào. Đó chính là lý do có thành viên tĩnh. Ngoài việc đếm, thành viên tĩnh còn hay dùng để phát mã số tự tăng, giữ một hằng số chung, hay lưu một cấu hình mà mọi đối tượng cùng đọc.

Ranh giới cần phân biệt ngay: thuộc tính thường sinh ra và mất đi cùng từng đối tượng, còn thuộc tính tĩnh tồn tại độc lập, có sẵn trước khi tạo đối tượng đầu tiên và còn đó sau khi đối tượng cuối cùng bị hủy.

Có một cách hình dung giúp mọi thứ sáng ra. Hãy coi lớp như một tờ khai sinh viên: mỗi sinh viên điền một tờ riêng, có họ tên riêng, điểm riêng. Đó là thuộc tính thường. Bây giờ hãy nghĩ tới tấm bảng treo ở cửa phòng đào tạo ghi tổng số sinh viên toàn khoa. Tấm bảng ấy không thuộc về sinh viên nào cả, nó thuộc về cả khoa. Nó có mặt từ trước khi sinh viên đầu tiên nhập học, và vẫn còn đó sau khi người cuối cùng ra trường. Ai cũng nhìn được cùng một tấm bảng, và khi ai đó sửa con số thì mọi người đều thấy con số mới. Thành viên tĩnh chính là tấm bảng đó, còn thuộc tính thường là tờ khai của từng người.

Từ hình ảnh này, một loạt tính chất trở thành hệ quả tự nhiên chứ không phải luật lệ phải học thuộc. Vì tấm bảng không nằm trong tờ khai nào, kích thước tờ khai không đổi dù có thêm bao nhiêu tấm bảng: thêm một thuộc tính tĩnh vào lớp không làm đối tượng to ra. Vì tấm bảng thuộc về cả khoa, ta gọi tên nó qua tên khoa chứ không qua tên một sinh viên: đó là cú pháp Sv::dem. Và vì chỉ có duy nhất một tấm bảng, phải có đúng một chỗ trong tòa nhà thật sự treo nó lên, chứ không phải mỗi phòng lại treo một bản: đó chính là lý do phải có dòng định nghĩa ngoài lớp mà ta sẽ nói kỹ ở dưới.

Ý tưởng cốt lõi

  • static int dem; nằm ở vùng tĩnh, không nằm trong từng đối tượng.
  • Phải định nghĩa một lần bên ngoài lớp: int Sv::dem = 0;.
  • Truy cập qua tên lớp: Sv::dem (không cần đối tượng nào cả).

Mã nguồn

#include <iostream>
using namespace std;

class Sv {
public:
static int dem; // CHUNG cho mọi đối tượng
Sv() { dem++; } // mỗi lần tạo -> tăng đếm
};
int Sv::dem = 0; // định nghĩa vùng tĩnh

int main() {
Sv a; // dem = 1
Sv b; // dem = 2
Sv c; // dem = 3
cout << Sv::dem; // 3
}

Thử ngay: xem thành viên tĩnh chạy từng bước

Một ô nhớ dem dùng chung cho mọi đối tượng C++
1class Sv {
2public:
3 static int dem;
4 Sv() { dem++; }
5};
6int Sv::dem = 0;
7
8int main() {
9 Sv a;
10 Sv b;
11 Sv c;
12 cout << Sv::dem;
13}
Ngăn xếp stack
Vùng tĩnh (static)
Sv::dem= 0
main()
Bộ nhớ động heap
(trống)
Trước khi chạy main: vùng tĩnh có sẵn ô Sv::dem = 0 (chỉ MỘT ô, không thuộc đối tượng nào).
1/5

Biến static so với thành viên thường

Điểm khác biệt nằm ở bố trí bộ nhớ. Mỗi thuộc tính thường có một ô riêng bên trong mỗi đối tượng, nên tạo ba đối tượng thì có ba ô. Thuộc tính tĩnh chỉ có đúng một ô đặt ở vùng nhớ tĩnh của chương trình, dùng chung cho mọi đối tượng, dù có tạo ba hay ba nghìn đối tượng vẫn chỉ một ô. Vì thuộc tính tĩnh không nằm trong đối tượng, nó không cần đối tượng nào để tồn tại: ta truy cập nó qua tên lớp, Sv::dem, ngay cả khi chưa tạo đối tượng nào.

Bảng đối chiếu nhanh giúp nhớ:

  • Thuộc tính thường: mỗi đối tượng một bản, truy cập qua đối tượng (a.diem), sinh và mất cùng đối tượng.
  • Thuộc tính tĩnh: một bản duy nhất cho cả lớp, truy cập qua tên lớp (Sv::dem), tồn tại độc lập với đối tượng.

Hệ quả về bộ nhớ đáng để dừng lại. Nếu một lớp có ba thuộc tính thường kiểu int, mỗi đối tượng chiếm khoảng mười hai byte. Thêm vào lớp đó một thuộc tính tĩnh kiểu int, kích thước mỗi đối tượng vẫn không đổi, vì ô tĩnh nằm ở nơi khác, trong vùng nhớ tĩnh của chương trình. Đây là kiểm chứng thực tế cho câu khẩu quyết thành viên tĩnh thuộc về lớp chứ không thuộc về đối tượng: nó thậm chí không có mặt trong hình hài của đối tượng.

Vòng đời cũng khác hẳn. Thuộc tính thường được hàm dựng khởi tạo và bị hàm hủy dọn đi, đúng nhịp sống của từng đối tượng. Thuộc tính tĩnh thì được khởi tạo trước khi hàm main bắt đầu chạy và chỉ bị dọn khi chương trình kết thúc. Đó là lý do đoạn mã ở phần bài tập in ra 0 khi gọi Sv::demSo() lúc chưa tạo đối tượng nào: ô dem đã tồn tại và đã mang giá trị 0, hoàn toàn không cần một đối tượng nào ra đời trước.

Một điểm hay gây nhầm cuối cùng: về cú pháp, C++ vẫn cho phép truy cập thành viên tĩnh qua một đối tượng, tức là viết a.dem thay vì Sv::dem. Cả hai đều biên dịch và đều chỉ tới cùng một ô nhớ. Nhưng lối viết a.dem gieo cho người đọc một hiểu lầm tai hại, rằng dem là của riêng a, và rằng hai đối tượng khác nhau có hai giá trị dem khác nhau. Hãy luôn viết Sv::dem, vì cú pháp nên nói đúng sự thật về mặt ngữ nghĩa.

Khởi tạo biến static, chỗ hay quên

Khai báo static int dem; bên trong lớp mới chỉ là một lời hứa rằng ô tĩnh tồn tại, chứ chưa cấp phát ô nhớ thật. Với thuộc tính tĩnh kiểu thường, ta phải viết thêm đúng một dòng định nghĩa bên ngoài lớp để cấp phát và khởi tạo:

class Sv {
public:
static int dem; // khai báo bên trong lớp
};
int Sv::dem = 0; // định nghĩa bên ngoài, cấp ô nhớ thật

Dòng int Sv::dem = 0; phải đặt ở phạm vi tệp, ngoài mọi hàm, và chỉ xuất hiện đúng một lần trong toàn chương trình. Nếu quên, trình liên kết báo lỗi thiếu định nghĩa. Riêng với hằng tĩnh kiểu nguyên, ta được phép khởi tạo ngay trong lớp nên không cần dòng ngoài:

class Sv {
public:
static const int SI_SO_TOI_DA = 30; // hằng nguyên, khởi tạo tại chỗ
};

Từ chuẩn C++17, ta còn một lối viết gọn hơn cho mọi thuộc tính tĩnh, kể cả loại không phải hằng: thêm từ khóa inline để vừa khai báo vừa định nghĩa ngay trong lớp, khỏi cần dòng ngoài.

class Sv {
public:
inline static int dem = 0; // C++17: khai báo và định nghĩa cùng chỗ
};
// không cần viết int Sv::dem = 0; ở ngoài nữa

Hai cách đều đúng. Bài này vẫn trình bày cách định nghĩa ngoài lớp trước, vì đó là cách cổ điển mà bạn sẽ gặp trong hầu hết mã nguồn cũ và trong đề thi.

Vì sao lại phiền phức đến vậy

Câu hỏi rất chính đáng: sao không cho viết thẳng static int dem = 0; trong lớp cho xong, như mọi ngôn ngữ khác? Câu trả lời nằm ở cách C++ dịch chương trình theo từng tệp riêng lẻ.

Định nghĩa lớp gần như luôn nằm trong một tệp tiêu đề, và tiêu đề ấy được #include vào nhiều tệp .cpp. Trình biên dịch dịch từng tệp .cpp một cách độc lập, rồi trình liên kết mới ghép các kết quả lại. Bây giờ hãy hình dung nếu lời khai static int dem; trong lớp cũng đồng thời cấp phát ô nhớ. Mỗi tệp .cpp include tiêu đề đó sẽ cấp một ô Sv::dem riêng. Khi ghép lại, trình liên kết thấy năm ô cùng tên và không biết chọn ô nào, nên báo lỗi trùng định nghĩa. Cả ý tưởng một ô duy nhất cho cả chương trình sụp đổ.

Vì thế C++ tách bạch hai việc. Khai báo bên trong lớp chỉ nói rằng có một thứ tên dem kiểu int thuộc về lớp Sv, và lời khai này được lặp lại vô hại ở mọi tệp. Định nghĩa bên ngoài lớp mới thật sự cấp ô nhớ, và nó phải xuất hiện đúng một lần trong toàn chương trình, nên ta đặt nó vào một tệp .cpp duy nhất. Nếu quên hẳn dòng định nghĩa, mọi tệp đều dịch trôi chảy vì lời khai có đủ, nhưng trình liên kết đi tìm ô nhớ thật thì không thấy, và báo lỗi thiếu định nghĩa. Đây là lý do bạn thấy lỗi ở khâu liên kết chứ không phải khâu biên dịch, và cũng là lý do thông báo lỗi trông xa lạ với người mới.

Hiểu vậy rồi thì inline static của C++17 hết bí ẩn. Từ khóa inline chính là lời hứa với trình liên kết rằng các bản định nghĩa trùng nhau ở nhiều tệp là cố ý và giống hệt nhau, cứ giữ lại một bản là đủ. Nhờ lời hứa đó, ta được phép vừa khai báo vừa định nghĩa ngay trong lớp mà không sợ trùng, và cái phiền phức lịch sử biến mất. Nếu dự án của bạn dùng C++17 trở lên, inline static là lựa chọn nên ưu tiên vì nó giữ mọi thứ về thành viên tĩnh gọn trong một chỗ.

Hàm static và khi nào dùng

Phương thức cũng có thể là static. Một phương thức tĩnh không gắn với đối tượng cụ thể nào, nên nó không có con trỏ this và chỉ chạm được vào thành viên tĩnh, không đọc được thuộc tính thường của một đối tượng. Đổi lại, ta gọi nó qua tên lớp mà không cần đối tượng. Đây là cánh cửa đọc hoặc thao tác an toàn cho dữ liệu tĩnh đang được che.

#include <iostream>
using namespace std;

class Sv {
private:
static int dem; // đếm dùng chung, được che
public:
Sv() { dem++; }
~Sv() { dem--; }
static int demSo() { // hàm tĩnh: gọi qua Sv::demSo()
return dem;
}
};
int Sv::dem = 0;

int main() {
cout << Sv::demSo() << "\n"; // 0, chưa có đối tượng nào
Sv a, b;
cout << Sv::demSo(); // 2
}

Vậy khi nào chọn tĩnh, khi nào chọn thường? Nếu dữ liệu hay hành vi gắn với một đối tượng cụ thể (điểm của một sinh viên, tên của một tài khoản) thì để thường. Nếu nó gắn với cả lớp và có ý nghĩa ngay cả khi chưa có đối tượng nào (tổng số đối tượng, bộ phát mã số, một hằng giới hạn chung) thì để tĩnh.

Không có this nghĩa là gì

Nói hàm tĩnh không có this nghe hơi trừu tượng, nên hãy nhìn vào cơ chế bên dưới. Khi ta viết a.tinhDiem() với một phương thức thường, trình biên dịch lặng lẽ truyền thêm một tham số ẩn: địa chỉ của a. Bên trong thân hàm, mỗi lần ta nhắc tới diem, thật ra ta đang viết tắt cho this->diem, tức là đọc thuộc tính của đối tượng đã gọi hàm. Toàn bộ khả năng chạm vào dữ liệu riêng của một đối tượng đến từ tham số ẩn ấy.

Hàm tĩnh được gọi bằng Sv::demSo(), không đi kèm đối tượng nào, nên đơn giản là không có gì để truyền vào tham số ẩn. Không có this thì không có đối tượng nào để nói tới, và câu hỏi lấy diem của ai trở thành vô nghĩa. Vì thế nếu bạn viết return diem; trong một hàm tĩnh, trình biên dịch báo lỗi ngay: nó không hề thiếu thông tin gì bí ẩn, nó chỉ đang chỉ ra rằng câu lệnh của bạn không có nghĩa. Hiểu như vậy thì hạn chế của hàm tĩnh không còn là một điều luật cần học thuộc, mà là hệ quả hiển nhiên.

Chiều ngược lại thì hoàn toàn thoải mái: phương thức thường truy cập được thành viên tĩnh mà không vướng gì, vì thành viên tĩnh chẳng cần đối tượng nào. Đó là lý do hàm dựng, vốn là một phương thức thường, viết được dem++ một cách tự nhiên.

Cũng nên biết một mẹo dùng hàm tĩnh mà bạn sẽ gặp trong mã thật: chính vì gọi được khi chưa có đối tượng nào, hàm tĩnh là nơi lý tưởng để đặt các hàm nhà máy, tức những hàm tạo và trả về đối tượng theo một cách có kiểm soát. Một hàm tĩnh có thể kiểm tra dữ liệu đầu vào, từ chối tạo đối tượng nếu dữ liệu sai, hoặc chọn kiểu con phù hợp để tạo. Hàm dựng không làm được những việc đó một cách gọn gàng, vì lúc hàm dựng chạy thì đối tượng đã bắt đầu hình thành rồi.

Thành viên tĩnh so với biến toàn cục

Người mới thường thắc mắc, và thắc mắc rất đúng: một biến tĩnh của lớp với một biến toàn cục đặt ngoài lớp, xét cho cùng, đều là một ô nhớ duy nhất sống suốt chương trình. Vậy khác nhau ở đâu mà phải bày ra thêm một cú pháp?

Khác nhau không nằm ở bộ nhớ, mà nằm ở quyền và nghĩa. Biến toàn cục nằm trơ ra ngoài, không thuộc về ai, nên bất kỳ dòng mã nào trong chương trình cũng sửa được nó. Khi con số đếm sinh viên bị sai, bạn phải rà soát toàn bộ mã nguồn để tìm thủ phạm, vì bất cứ đâu cũng có thể là nơi gây ra. Biến tĩnh đặt trong vùng private của lớp thì ngược lại: chỉ các phương thức của lớp mới chạm được vào nó. Muốn tăng đếm, phải qua hàm dựng; muốn đọc đếm, phải qua hàm tĩnh mà lớp cho phép. Khi con số sai, phạm vi nghi vấn thu hẹp lại đúng một lớp. Đó là toàn bộ tinh thần đóng gói, chỉ khác là lần này ta đóng gói một dữ liệu chung thay vì một dữ liệu riêng.

Còn một khác biệt về đặt tên. Biến toàn cục chiếm một cái tên trong không gian tên chung của cả chương trình, nên hai thư viện cùng đặt tên dem là xung đột. Biến tĩnh mang họ của lớp, tên đầy đủ là Sv::dem, nên lớp khác có HocPhan::dem của riêng nó mà không ai đụng ai. Cái tên tự nó cũng nói lên ý nghĩa: đọc Sv::dem là biết ngay đang đếm cái gì, còn một biến tên dem lơ lửng ngoài lớp thì phải đi tìm mới hiểu.

Thành viên tĩnh vẫn là trạng thái dùng chung

Đóng gói tốt hơn biến toàn cục không có nghĩa là vô hại. Thành viên tĩnh vẫn là trạng thái dùng chung, và trạng thái dùng chung luôn kèm ba cái giá.

  • Khó kiểm thử. Vì ô tĩnh sống suốt chương trình, nó mang giá trị của phép thử trước sang phép thử sau. Một bộ kiểm thử chạy đúng khi đứng riêng lại sai khi chạy chung, chỉ vì bộ đếm chưa được đưa về 0.
  • Không an toàn khi đa luồng. Hai luồng cùng chạy dem++ có thể giẫm lên nhau và làm mất một lần đếm, vì phép tăng không phải một thao tác nguyên tử. Muốn an toàn phải dùng khóa hoặc kiểu nguyên tử.
  • Thứ tự khởi tạo giữa các tệp là không xác định. Nếu một thuộc tính tĩnh ở tệp này khởi tạo bằng cách đọc một thuộc tính tĩnh ở tệp khác, bạn không có gì bảo đảm cái kia đã sẵn sàng.

Nguyên nhân gốc của cả ba là như nhau: dữ liệu tĩnh nằm ngoài vòng đời của mọi đối tượng, nên nó cũng nằm ngoài mọi cơ chế kiểm soát mà lập trình hướng đối tượng dựa vào. Hãy dùng thành viên tĩnh khi dữ liệu thật sự thuộc về cả lớp, đừng dùng nó chỉ vì tiện tay chia sẻ dữ liệu giữa các nơi.

Lỗi thường gặp với thành viên tĩnh
  • Quên dòng định nghĩa ngoài lớp int Sv::dem = 0;: chương trình biên dịch nhưng không liên kết được.
  • Đặt lại static ở dòng định nghĩa ngoài: viết int Sv::dem = 0;, không viết static int Sv::dem = 0;. Từ khóa static chỉ đặt ở khai báo trong lớp.
  • Truy cập thuộc tính thường từ trong hàm tĩnh: hàm tĩnh không có this nên không biết đối tượng nào, đọc thẳng một thuộc tính thường sẽ lỗi biên dịch.
  • Tăng đếm trong hàm dựng mà quên giảm trong hàm hủy: nếu muốn dem phản ánh số đối tượng đang sống, hàm hủy phải dem--.

Bài tập thực hành

Bài tập 1: đọc số đối tượng bằng phương thức tĩnh

Truy cập trực tiếp Sv::dem khiến biến đếm bị lộ ra ngoài, ai cũng sửa được. Hãy chuyển dem thành thành viên private và viết một phương thức static int demSo() để đọc giá trị đếm. Trong main, tạo vài đối tượng rồi in số lượng qua Sv::demSo().

Lời giải chi tiết

dem cần được giấu, ta đặt nó vào vùng private. Phương thức đọc phải là static để gọi được qua tên lớp ngay cả khi chưa có đối tượng nào, đúng như cách dem tồn tại độc lập với từng đối tượng. Phương thức tĩnh không có con trỏ this, nên nó chỉ chạm vào thành viên tĩnh, ở đây là dem, điều đó hoàn toàn hợp lệ.

#include <iostream>
using namespace std;

class Sv {
private:
static int dem; // hidden shared counter
public:
Sv() { dem++; }
static int demSo() { // static reader, callable as Sv::demSo()
return dem;
}
};
int Sv::dem = 0; // one real allocation in static storage

int main() {
Sv a;
Sv b;
cout << Sv::demSo(); // 2
}

Phương thức tĩnh là cánh cửa đọc an toàn cho biến tĩnh đang được giấu, gọi qua tên lớp mà không cần đối tượng.

Bài tập 2: cấp ID tự tăng cho mỗi đối tượng

Mỗi sinh viên cần một mã id riêng, tăng dần 1, 2, 3... theo thứ tự tạo. Hãy dùng một biến static int nextId làm bộ phát số, và một thuộc tính thường int id cho từng đối tượng. Constructor lấy số hiện tại làm id rồi tăng bộ phát lên. In id của ba đối tượng để kiểm tra.

Lời giải chi tiết

Có hai loại biến ở đây: nextId là biến tĩnh, chung một ô cho cả lớp, đóng vai trò bộ phát số; còn id là biến thường, mỗi đối tượng giữ một bản riêng. Trong constructor, ta gán id = nextId để lấy số đang chờ, sau đó nextId++ để đối tượng kế tiếp nhận số khác. Nhờ vậy mỗi đối tượng có một mã duy nhất mà không cần truyền tay.

#include <iostream>
using namespace std;

class Sv {
private:
static int nextId; // shared ID generator
int id; // per-object identity
public:
Sv() {
id = nextId; // take the current number
nextId++; // advance for the next object
}
int layId() const { return id; }
};
int Sv::nextId = 1; // IDs start at 1

int main() {
Sv a, b, c;
cout << a.layId() << " "
<< b.layId() << " "
<< c.layId(); // 1 2 3
}

Một biến tĩnh dùng làm bộ phát, kết hợp một biến thường để lưu kết quả, là mẫu chuẩn để đánh số tự tăng cho từng đối tượng.

Bài tập 3: hằng tĩnh dùng chung và giới hạn sĩ số

Một lớp học có sĩ số tối đa cố định, dùng chung cho mọi đối tượng và không bao giờ đổi. Hãy khai báo một hằng tĩnh static const int SI_SO_TOI_DA = 30. Mỗi lần tạo đối tượng, nếu số hiện có đã đạt mức tối đa thì in cảnh báo và không tăng đếm; ngược lại thì tăng đếm bình thường. Viết phương thức tĩnh conTrong() cho biết còn chỗ hay không.

Lời giải chi tiết

Hằng tĩnh SI_SO_TOI_DA vừa là dữ liệu chung cho cả lớp (nên là static), vừa không đổi (nên là const). Với kiểu nguyên, ta khởi tạo ngay trong lớp nên không cần dòng định nghĩa ngoài. Biến đếm dem vẫn cần dòng định nghĩa ngoài như thường lệ. Constructor so sánh dem với mức tối đa trước khi tăng, còn conTrong() là phương thức tĩnh trả về kết quả so sánh, dùng được mà không cần đối tượng nào.

#include <iostream>
using namespace std;

class Sv {
private:
static int dem; // current count
public:
static const int SI_SO_TOI_DA = 30; // shared, fixed limit
Sv() {
if (dem >= SI_SO_TOI_DA) {
cout << "Da day, khong them duoc\n";
return; // do not count this one
}
dem++;
}
static bool conTrong() { // any seat left?
return dem < SI_SO_TOI_DA;
}
static int demSo() { return dem; }
};
int Sv::dem = 0; // define the counter

int main() {
Sv a, b;
cout << Sv::demSo() << "\n"; // 2
cout << (Sv::conTrong() ? "Con cho" : "Het cho");
}

Hằng tĩnh giữ một quy tắc chung bất biến cho cả lớp, còn phương thức tĩnh đọc trạng thái chung mà không phụ thuộc đối tượng cụ thể nào.

Bài tập 4: đếm số đối tượng đang sống cho đúng

Hãy viết lớp Ket sao cho một phương thức tĩnh dangSong() luôn cho biết chính xác số đối tượng Ket hiện đang tồn tại, kể cả khi đối tượng được tạo bằng sao chép, được gán, hay được tạo trong một khối rồi ra khỏi khối đó. Dùng inline static của C++17 để khỏi phải viết dòng định nghĩa ngoài lớp.

Lời giải chi tiết

Bài này gài một cái bẫy nhẹ. Nếu chỉ tăng đếm trong hàm dựng mặc định và giảm trong hàm hủy, con số sẽ sai ngay khi ai đó sao chép một đối tượng: bản sao được tạo ra bởi hàm dựng sao chép, một hàm dựng khác, và nếu ta quên tăng đếm ở đó thì bản sao ra đời mà không được ghi nhận. Đến lúc nó bị hủy, hàm hủy vẫn giảm đếm như thường, nên con số tụt xuống dưới mức thật và có thể xuống âm. Nguyên tắc để không bao giờ sai: mọi con đường sinh ra một đối tượng đều phải tăng đếm, và chỉ có một con đường hủy nên chỉ có một chỗ giảm đếm.

Phép gán thì khác: nó không tạo ra đối tượng mới, chỉ ghi đè nội dung của một đối tượng đã có. Vì vậy toán tử gán không được đụng vào bộ đếm. Đây là chỗ nhiều người nhầm, cứ thấy có liên quan tới sao chép là tăng đếm.

Ta dùng inline static int soLuong = 0; để vừa khai báo vừa định nghĩa ngay trong lớp, hợp lệ từ C++17, nên không cần dòng int Ket::soLuong = 0; bên ngoài. Biến đếm để private cho kín, và mở ra một hàm tĩnh dangSong() làm cửa đọc.

#include <iostream>
#include <string>
using namespace std;

class Ket {
private:
inline static int soLuong = 0; // C++17: declare and define in place
string ten;
public:
Ket(const string& t) : ten(t) { soLuong++; } // normal constructor
Ket(const Ket& khac) : ten(khac.ten) { soLuong++; } // copy constructor counts too
~Ket() { soLuong--; } // the only way out

// Assignment does NOT create a new object, so it must not touch the counter.
Ket& operator=(const Ket& khac) {
ten = khac.ten;
return *this;
}

static int dangSong() { return soLuong; } // no this, reads shared state
};

int main() {
cout << Ket::dangSong() << "\n"; // 0, chua co doi tuong nao
Ket a("A");
Ket b = a; // copy constructor -> 2
cout << Ket::dangSong() << "\n"; // 2
{
Ket c("C"); // -> 3
cout << Ket::dangSong() << "\n"; // 3
} // c bi huy o day -> 2
cout << Ket::dangSong() << "\n"; // 2
b = a; // assignment, no new object -> van 2
cout << Ket::dangSong(); // 2
}

Ba điều đáng rút ra. Thứ nhất, hàm dựng sao chép cũng là một hàm dựng, nên nó phải tăng đếm; bỏ sót nó là nguồn sai phổ biến nhất của mẫu đếm đối tượng. Thứ hai, toán tử gán không tạo đối tượng nên không được động vào bộ đếm. Thứ ba, đối tượng c bị hủy đúng lúc ra khỏi khối ngoặc, hàm hủy chạy và bộ đếm tự trở về 2 mà ta không phải làm gì thêm: đó là sức mạnh của việc buộc bộ đếm vào đúng vòng đời đối tượng.

Tự kiểm tra

Nếu xóa dòng int Sv::dem = 0; thì sao?

Chương trình không liên kết được (linker error: undefined reference to Sv::dem). Khai báo static int dem; bên trong lớp chỉ hứa rằng ô tĩnh tồn tại; phải có đúng một định nghĩa bên ngoài để cấp phát ô nhớ thật.

Câu hỏi tự kiểm

Kiểm tra nhanh: thành viên tĩnh0/4 đúngchưa trả lời
  1. 1Thuộc tính static của một lớp được lưu ở đâu?
  2. 2Nếu xóa dòng định nghĩa ngoài lớp int Sv::dem = 0; (và không dùng inline static) thì điều gì xảy ra?
  3. 3Vì sao một hàm static không đọc được thuộc tính thường của một đối tượng?
  4. 4Trong mẫu đếm số đối tượng đang sống, vì sao hàm dựng sao chép cũng phải tăng đếm nhưng toán tử gán thì không?

Tóm tắt nhanh

  • Thuộc tính staticmột ô nhớ duy nhất dùng chung cho cả lớp, không nằm trong từng đối tượng.
  • Phải định nghĩa lại một lần bên ngoài lớp (int Sv::dem = 0;), trừ hằng nguyên khởi tạo được tại chỗ hoặc khi dùng inline static của C++17.
  • Hàm static không có con trỏ this, chỉ chạm thành viên tĩnh, gọi qua tên lớp.
  • Truy cập thành viên tĩnh qua TenLop::thanhVien, không cần đối tượng.
  • Chọn tĩnh khi dữ liệu thuộc về cả lớp, chọn thường khi dữ liệu thuộc về từng đối tượng.

Nhìn lại toàn bài

Mọi thứ trong bài này chảy ra từ một câu duy nhất: thành viên tĩnh thuộc về lớp, không thuộc về đối tượng. Vì nó không nằm trong đối tượng nên đối tượng không to ra khi ta thêm nó, và ta gọi nó qua tên lớp chứ không qua tên một đối tượng. Vì nó chỉ có một bản duy nhất cho cả chương trình nên phải có đúng một chỗ cấp phát ô nhớ thật, và đó là dòng định nghĩa ngoài lớp mà người mới hay quên, hoặc là từ khóa inline static từ C++17 gộp mọi thứ vào một chỗ. Vì nó không gắn với đối tượng nào nên hàm tĩnh không nhận con trỏ this, và vì không có this nên nó không thể, và cũng không có lý do gì để, đọc thuộc tính riêng của một đối tượng.

Về cách quyết định, hãy tự hỏi một câu đơn giản trước khi gõ từ khóa static: dữ liệu này còn có nghĩa không khi chưa có đối tượng nào ra đời? Tổng số đối tượng, bộ phát mã số, sĩ số tối đa đều trả lời có, nên chúng là thành viên tĩnh. Điểm của một sinh viên, tên của một tài khoản đều trả lời không, nên chúng phải là thuộc tính thường. Câu hỏi ấy hầu như luôn cho ra câu trả lời đúng.

Cuối cùng, đừng quên rằng thành viên tĩnh, dù đóng gói gọn gàng hơn biến toàn cục nhờ được che sau vùng private và mang họ của lớp, vẫn là trạng thái dùng chung với đầy đủ những cái giá của nó: khó kiểm thử độc lập, không an toàn khi nhiều luồng cùng chạm, và phụ thuộc vào thứ tự khởi tạo mà ta không kiểm soát. Nó là một công cụ sắc, dùng đúng chỗ thì gọn và diễn đạt đúng ý đồ thiết kế; dùng chỉ vì tiện tay chia sẻ dữ liệu thì sớm muộn cũng trả giá.

Ghi nhớ

static = thuộc về lớp, không thuộc đối tượng. Một ô duy nhất, mọi đối tượng chia sẻ. Truy cập qua TenLop::thanhVien. Mẫu kinh điển: đếm số thực thể đang sống.