Lỗi thứ tự khởi tạo tĩnh trong C++ là gì?

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.cppcó một đối tượng tĩnh thuộc loại lớp A – aObjFile2.cppcó 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.cppvì nó khai báo aObjnhư externtrong 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 aObjtrong File1.cpp được khởi tạo trước bObjtrong File2.cpp. Tất cả đều tốt vì trong trường hợp đó, hàm tạo cho bObjcá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 bObjtrong File2.cpp được khởi tạo trước aObjtrong 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 aObjbị hủy trước bObj. Đây là một vấn đề nếu bObjhà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 bObjlà 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 bObjvà 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 aObjbằ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;

bObjcó 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 đủ aObjvì 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 aObjtrong bObjhàm hủy của .

Nhưng điều này không có nghĩa là bộ nhớ được phân bổ aObjluô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 aObjhàm hủy đó thực hiện điều gì đó mong muốn. Ví dụ: khi aObjbị 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 aObjhàm hủy của ‘ chạy trước bObjhà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:

  1. Đố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.
  2. Đố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.hcó đị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 AInitializerchạ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.hbao 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? aObjcó 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ì newtoá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 newthay 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 newtoá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 Avà 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 AInitializerhà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ớ aObjBufvẫ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.