Trong bài viết này, Học với Chuyên Gia sẽ đề cập đến một vấn đề tế nhị nhưng nghiêm trọng có thể xảy ra trong các chương trình C++. Sự cố này thường được gọi là ‘Lỗi khởi tạo tĩnh’.
Trước tiên Học với Chuyên Gia sẽ xem vấn đề là gì, sau đó đi vào một số giải pháp và khám phá cách chúng hoạt động. Bắt đầu nào.
1. Điều kiện tiên quyết
- Hiểu biết cơ bản về C++
- Đặc biệt, sự hiểu biết về các lớp lưu trữ trong C++ sẽ rất hữu ích.
2. ‘Lỗi khởi tạo tĩnh’ trong C++ là gì?
Tiêu chuẩn C++ nêu rõ:
“Thứ tự khởi tạo các đối tượng tĩnh trên các đơn vị dịch thuật khác nhau là không xác định hoặc không rõ ràng .”
Đơn vị dịch chỉ là một cách diễn đạt một tệp được đưa vào trình biên dịch. Đó là tệp nguồn C++ có tất cả mã từ các tiêu đề được bao gồm trong đó.
Tuy nhiên, có một điều cần lưu ý ở phần sau của bài viết: các đối tượng tĩnh trong cùng một đơn vị dịch thuật được xây dựng theo thứ tự khai báo và bị hủy theo thứ tự ngược lại.
Vì vậy, vấn đề này là như thế nào?
Nó có thể là một vấn đề trong tình huống sau:
Giả sử có 2 đối tượng tĩnh trong 2 tệp khác nhau. File1.cpp
có một đối tượng tĩnh thuộc loại lớp A – aObj
. File2.cpp
có một đối tượng tĩnh thuộc loại lớp B – bObj
. Đối tượng tĩnh trong File1.cpp
được hiển thị File2.cpp
vì nó khai báo aObj
như extern
trong File1.h
.
// Static initialization order problem
// File1.h
class A {
....
void doSomething() {
...
}
}
extern A aObj;
//File1.cpp
static A aObj;
// File2.cpp
class B {
B() {
aObj.doSomething();// Not okay! aObj may not have been constructed
}
....
}
static B bObj;
Trong chương trình này, có thể đối tượng aObj
trong File1.cpp được khởi tạo trước bObj
trong File2.cpp. Tất cả đều tốt vì trong trường hợp đó, hàm tạo cho bObj
các lần chạy sau aObj
đã được xây dựng. Việc gọi các phương thức gọi trên aObj
.
Nhưng cũng có thể đối tượng bObj
trong File2.cpp được khởi tạo trước aObj
trong File1.cpp. Trong trường hợp đó, hàm tạo của các bObj
cuộc gọi chưa được xây dựng! Bộ nhớ đã được phân bổ cho , nhưng nó chưa được xây dựng. Điều này có thể dẫn đến hành vi ngoài ý muốn/chương trình bị hỏng.doSomething()
aObj
aObj
Vì vậy, đây chính là lỗi của lệnh khởi tạo tĩnh.
Nhưng Học với Chuyên Gia vẫn chưa xong: vấn đề còn lại là lỗi thứ tự khởi tạo tĩnh ! Đây gần như là một vấn đề tương tự, chỉ áp dụng cho thứ tự khởi tạo lại các đối tượng tĩnh.
Tiêu chuẩn C++ không chỉ định thứ tự các đối tượng tĩnh cũng được khởi tạo lại. Vì vậy, có thể đối tượng tĩnh aObj
bị hủy trước bObj
. Đây là một vấn đề nếu bObj
hàm hủy của nó sử dụng hoặc tham chiếu aObj
.
Điều này được minh họa trong đoạn mã bên dưới – nó khá giống với ví dụ trên, chỉ có điều lần này thứ tự khởi tạo lại rất nguy hiểm:
// Static de-initialization order problem
// File1.h
class A {
....
void doSomething() {
...
}
}
extern A aObj;
//File1.cpp
static A aObj;
// File2.cpp
class B {
B() {}
~B() {
aObj.doSomething(); // Not okay! aObj may have already been destructed!
}
....
}
static B bObj;
Lưu ý: Những sự cố này chỉ áp dụng được cho các đối tượng có phạm vi lưu trữ tĩnh . Chúng sẽ không xảy ra nếu bObj
là một biến có phạm vi lưu trữ tự động. Trong trường hợp đó, tiêu chuẩn C++ đảm bảo rằng nó aObj
được xây dựng trước bObj
và bị hủy sau nó.
Một lưu ý khác: Những vấn đề này cũng không xảy ra trong các chương trình C. Tại sao lại như vậy? Chà, trong C không có khái niệm về hàm tạo và hàm hủy. Các đối tượng tĩnh được xác định hoàn toàn trong thời gian biên dịch.
3. Cách giải quyết vấn đề thứ tự khởi tạo lại tĩnh
Bây giờ đã rõ vấn đề là gì, Học với Chuyên Gia sẽ thảo luận về một số giải pháp. Có nhiều cách để giải quyết vấn đề này – mỗi cách đều có sự cân bằng. Chúng ta hãy xem xét.
a. Xây dựng dựa trên idiom sử dụng đầu tiên:
Idiom này cố gắng đảm bảo rằng luôn có một đối tượng được xây dựng đầy đủ bất cứ khi nào đối tượng tĩnh được đề cập được sử dụng. Theo các ví dụ ở phần trước, chúng ta có thể thực hiện việc này bằng cách thay thế tất cả các tham chiếu aObj
bằng một lệnh gọi hàm aObj()
trả về một tham chiếu đến một đối tượng thuộc loại A
.
Trong mã nó trông như thế này:
// Static initialization order problem
// File1.h
class A {
....
void doSomething() {
...
}
};
A& aObj();
//File1.cpp
A& aObj() {
static A *aObj = new A();
return *aObj;
}
// File2.cpp
class B {
B() {
/*
* Okay since calling aObj() gaurantees that
* static A *aObj = new A(); ran
*/
aObj().doSomething();
}
....
};
static B bObj;
bObj
có thể giả định một cách an toàn rằng việc gọi aObj() trả về một kết quả được xây dựng đầy đủ aObj
vì dòng này:
static A *aObj = new A();
sẽ chạy theo lệnh gọi hàm và sẽ cung cấp cho nó một đối tượng được xây dựng hoàn chỉnh. Ngoài ra, vì chương trình không bao giờ gọi delete on aObj
, nên nó không bao giờ bị hủy nên nó cũng an toàn khi sử dụng aObj
trong bObj
hàm hủy của .
Nhưng điều này không có nghĩa là bộ nhớ được phân bổ aObj
luôn tồn tại và hợp lệ trong suốt vòng đời của chương trình. Và điều này có thể có hoặc không có vấn đề (tất nhiên nó sẽ được hệ điều hành thu hồi sau khi chương trình thoát).
Vậy giải pháp này không tuyệt vời trong trường hợp nào? Trong trường hợp aObj
hàm hủy đó thực hiện điều gì đó mong muốn. Ví dụ: khi aObj
bị hủy – nó ghi vào tệp nhật ký/thực hiện một số tác dụng phụ khác.
Bây giờ bạn có thể hỏi, được thôi, tại sao tôi không thay thế con trỏ tĩnh trong lệnh aObj()
gọi hàm bằng một đối tượng tĩnh aObj
?
A& aObj() {
static A aObj;
return aObj;
}
Điều đó vẫn đảm bảo rằng nó aObj
đã được xây dựng hoàn chỉnh vào thời điểm hàm được gọi phải không? Phải. Nhưng nó không cứu chúng ta khỏi vấn đề thứ tự khởi tạo tĩnh. Vẫn có khả năng aObj
hàm hủy của ‘ chạy trước bObj
hàm hủy của ‘.
Có một thủ thuật thú vị có thể giải quyết cả hai vấn đề này: idiom phản biện tiện lợi.
b. Giải pháp truy cập tiện lợi
Ý tưởng là để đảm bảo rằng:
- Đối tượng tĩnh đang được sử dụng sẽ được xây dựng trước bất kỳ đối tượng tĩnh nào khác trong đơn vị dịch thuật mà nó đang được sử dụng.
- Đối tượng tĩnh đang được sử dụng sẽ bị hủy sau bất kỳ đối tượng tĩnh nào khác trong đơn vị dịch thuật mà nó đang được sử dụng.
// File1.h
#pragma once
struct A {
A();
~A();
};
extern A& aObj;
static struct AInitializer {
AInitializer ();
~AInitializer ();
} aInitializer; // static initializer for every translation unit that aObj is used in
// File1.cpp
#include "File1.h"
#include <new> // Used for placement new
#include <type_traits> // Used for aligned_storage
static int niftyCounter; // this is zero initialized at load time
/*
* Memory for the static object aObj - memory itself is valid throughout the
* the lifetime of the program.
*/
static typename std::aligned_storage<sizeof (A), alignof (A)>::type
aObjBuf;
A& aObj = reinterpret_cast<A&> (aObj);
A::A ()
{
// Construct A
}
A::~A ()
{
/*
* Destruct A: with possible side effects
* like writing to a file.
*/
}
AInitializer::AInitializer ()
{
if (niftyCounter++ == 0) {
new (&aObj) A (); // use placement new operator
}
}
AInitializer::~AInitializer ()
{
if (--niftyCounter == 0) {
(&aObj)->~A(); // run the destructor
}
}
Chúng ta hãy cố gắng hiểu mã này làm gì.
Đầu tiên, trong tệp tiêu đề, File1.h
có định nghĩa class A
đầu tiên. Sau đó, có định nghĩa về một lớp được gọi là AInitializer
.
Ngoài ra còn có một đối tượng tĩnh được xác định trong tệp tiêu đề loại AInitializer
. Điều này đảm bảo rằng hàm tạo for AInitializer
chạy trước hàm tạo của bất kỳ đối tượng tĩnh nào khác trong đơn vị dịch thuật được File1.h
bao gồm trong (tất nhiên bạn phải bao gồm File1.h trước bất kỳ định nghĩa đối tượng tĩnh nào khác trong tệp nguồn).
Hãy nhớ: các đối tượng tĩnh trong cùng một đơn vị dịch được xây dựng theo thứ tự khai báo và bị hủy theo thứ tự ngược lại .
Vì vậy, bây giờ nó AInitializer
được xây dựng trước bất kỳ đối tượng tĩnh nào khác trong một đơn vị dịch thuật, làm cách nào chúng ta có thể sử dụng điều này để có lợi cho mình? aObj
có thể được xây dựng trong hàm tạo của AInitializer
! Đó là những gì đang xảy ra trong các dòng dưới đây:
AInitializer::AInitializer ()
{
if (nifty_counter++ == 0) {
new (&aObj) A (); // use placement new
}
}
Lưu ý rằng toán tử vị trí mới đang được sử dụng ở đây thay vì new
toán tử để xây dựng aObj
. Hãy xem điều gì sẽ xảy ra nếu chúng ta sử dụng new
thay thế. Mã sẽ trông như thế này:
A& aObj;
A *aObjp = nullptr;
AInitializer::AInitializer ()
{
if (nifty_counter++ == 0) {
aObjp = new A ();
aObj = *aObjp; // Not okay! Cannot re-assign a reference
}
}
Điều này không có tác dụng vì một tham chiếu cần được xác định và khai báo cùng một lúc. Đó chính xác là lý do tại sao new
toán tử vị trí cần được sử dụng.
static typename std::aligned_storage<sizeof (A), alignof (A)>::type
aObjBuf;
A& aObj = reinterpret_cast<A&> (aObj)
Điều này phân bổ bộ nhớ để phù hợp với một đối tượng thuộc loại A
và sau đó gán nó cho tham chiếu. Bây giờ tất cả những gì còn lại phải làm là thực sự xây dựng đối tượng trong AInitializer
hàm tạo của – đó là những gì được thực hiện với toán tử vị trí new .
Một câu hỏi khác có thể nảy sinh trong đầu bạn: ở đây có một đối tượng tĩnh aObjBuf
. Nhưng đó không phải là vấn đề tương tự về thứ tự khởi tạo lại mà chúng ta đã nói đến trong phần thứ hai của thành ngữ Xây dựng khi sử dụng lần đầu sao?
Câu trả lời là bộ nhớ aObjBuf
vẫn tồn tại và có hiệu lực cho đến khi chương trình còn tồn tại. Không có gì xảy ra trong quá trình xây dựng ký ức. Vì vậy, nó có giá trị cho điều này.
Cách tiếp cận này cũng đảm bảo rằng vấn đề thứ tự khởi tạo tĩnh không xảy ra, vì AInitializer
đối tượng cuối cùng bị hủy sẽ gọi hàm hủy của aObj
. Điều đó được đảm bảo chạy sau khi mọi đối tượng tĩnh trong các đơn vị dịch khác chạy, vì trong đơn vị dịch cụ thể, đối tượng tĩnh aInitializer
được khai báo trước bất kỳ đối tượng tĩnh nào khác sử dụng aObj
. Điều này có nghĩa là nó sẽ bị hủy theo thứ tự ngược lại – đó là sau khi hàm hủy cho bất kỳ đối tượng tĩnh nào khác đã chạy.
Có một số lưu ý ở đây: giải pháp này không phải là giải pháp dễ hiểu và dễ thực hiện nhất. Bạn có thể tìm hiểu những giải pháp khác.
Bản tóm tắt
Việc sử dụng các đối tượng được khởi tạo tĩnh trong C++ rất phức tạp và cần được thực hiện cẩn thận. May mắn thay, có nhiều giải pháp và cách để giải quyết vấn đề.
Trong bài viết này, Học với Chuyên Gia đề cập đến một số giải pháp phổ biến: thành ngữ ‘Xây dựng khi sử dụng lần đầu’ và ‘Giải pháp đối phó tiện lợi’, cùng với những ưu điểm và thách thức của chúng.