- Việc khởi tạo đối tượng trong C++ có những cú pháp trông tương tự như
T t;, T t{};, T t{...} nhưng lại tách thành default/value/list/aggregate initialization, nên trạng thái thực tế của các member có thể khác nhau
- Tùy vào nơi đặt
= default cho constructor mặc định, việc có zero-initialization hay không sẽ thay đổi; nếu định nghĩa bên ngoài class, các member có thể còn lại với giá trị chưa được khởi tạo
- Ngay cả kiểu có member
const int trông như sẽ bị xóa constructor mặc định, nếu là aggregate thì A a{}; có thể đi qua aggregate initialization và được khởi tạo thành 0
- Aggregate initialization dựa trên ngoặc tròn trong C++20 dễ dãi hơn khởi tạo bằng ngoặc nhọn, nên có thể tạo ra khác biệt như narrowing conversion và dangling reference
- Trong thực tế, thay vì dựa vào sự kết hợp giữa constructor ngầm định và các quy tắc khởi tạo, việc thể hiện ý định khởi tạo bằng constructor trực tiếp thường giúp tránh ngoại lệ dễ hơn
Những điểm cú pháp khởi tạo trong C++ rẽ nhánh
T t; thực hiện default-initialization
- Nếu
T là kiểu class và có constructor mặc định, constructor đó sẽ được chạy
- Nếu
T là mảng, từng phần tử được default-initialize
- Các trường hợp khác thì không làm gì cả
T t{}; trông giống value-initialization, nhưng do cú pháp ngoặc nhọn nên trước hết được xử lý như list-initialization
- Ngay cả trong ví dụ đơn giản, khác biệt cũng lộ ra ngay
int x{}; được value-initialized và khởi tạo thành 0
int y; được default-initialized nên giá trị không được khởi tạo
Pair p; và Pair q{}; có constructor mặc định sẽ gọi constructor
SimplePair r; chỉ có member sẽ được default-initialized, nên các member có thể không được khởi tạo
Vị trí khai báo constructor mặc định làm thay đổi kết quả
- Nếu người dùng không khai báo constructor, compiler sẽ khai báo constructor mặc định implicitly-declared
- Nếu dùng
T() = default; ngay ở khai báo đầu tiên bên trong class, theo tiêu chuẩn nó được xử lý gần như giống constructor được khai báo ngầm định
- Nếu constructor mặc định được khai báo ngầm định hoặc được defaulted tường minh không bị xóa, compiler sẽ cung cấp implicitly-defined default constructor
- Về mặt triển khai, nó phải tương đương với
T() {} có thân rỗng và danh sách khởi tạo member rỗng
- Trong đoạn mã sau,
t.x sẽ là 0
struct T {
int x;
T() = default;
};
T t{};
std::cout << t.x << std::endl;
- Vì
t được value-initialized, và constructor mặc định của T không phải user-provided, nên zero-initialization diễn ra trước, rồi constructor mặc định được gọi
= default bên ngoài class và constructor user-provided
- Cùng là
= default, nhưng nếu định nghĩa bên ngoài class thì nó trở thành constructor user-provided
struct T {
int x;
T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
- Khi đó
T::T() = default; không phải được defaulted ở khai báo đầu tiên, mà là constructor được định nghĩa là defaulted bên ngoài class
- Quy tắc value-initialization quy định rằng nếu có constructor mặc định user-provided hoặc deleted, thì sẽ thực hiện default-initialization
- Vì vậy trong ví dụ trên, chỉ constructor mặc định được chạy mà không có zero-initialization; constructor đó không làm gì nên
t.x trở thành giá trị rác
Khi constructor mặc định bị xóa
- Nếu compiler không thể tạo một constructor mặc định hợp lý, constructor mặc định được khai báo ngầm định có thể được định nghĩa là deleted
- Các điều kiện tiêu biểu gồm
- Có member tham chiếu phi tĩnh
- Có member phi tĩnh hoặc base class không trừu tượng mà việc default-construct hoặc destruct không phù hợp
- Có member phi tĩnh
const không có default member initializer, và member đó không phải const-default-constructible
- Nếu người dùng tự cung cấp constructor, compiler sẽ không cố định nghĩa constructor mặc định ngầm định, và cũng không tạo constructor deleted
- Dù có các điều kiện này, các quy tắc khởi tạo của C++ nhìn chung vẫn hoạt động rất dễ dãi
Khoảnh khắc aggregate initialization chen vào
struct A { const int x; }; A a{}; trông như sẽ không biên dịch vì constructor mặc định bị xóa, nhưng thực tế vẫn biên dịch và a.x là 0
- Điểm cốt lõi là
A là aggregate
- Aggregate là mảng, hoặc class thỏa mãn các điều kiện sau
- Không có constructor user-declared hoặc inherited
- Không có data member phi tĩnh trực tiếp private/protected
- Không có base class trực tiếp private/protected
- Không có hàm virtual hoặc base class virtual
- Khi list-initialization được thực hiện trên aggregate, trừ một số ngoại lệ nhất định, aggregate initialization sẽ được áp dụng
- Từng phần tử trong danh sách khởi tạo sẽ khởi tạo lần lượt từng phần tử của aggregate
- Nếu số phần tử trong danh sách không đủ, phần còn lại được khởi tạo bằng default member initializer nếu có
- Nếu không có default member initializer và không phải tham chiếu, nó được copy-initialized bằng danh sách khởi tạo rỗng
- Do đó
A a{}; không phải là gọi constructor, mà là trường hợp a.x được copy-list-initialized bằng danh sách rỗng, rồi qua value-initialization để được zero-initialization
Quy tắc bổ sung của khởi tạo bằng ngoặc nhọn
- Các cú pháp dựa trên ngoặc nhọn như
T t{...} và T t = {...} nhìn chung là list-initialization
T t{...} là direct-list-initialization
T t = {...} là copy-list-initialization
- Với kiểu class không phải aggregate, nếu danh sách khởi tạo rỗng và có constructor mặc định, value-initialization sẽ được thực hiện
- Ngoài ra, constructor được xét thông qua overload resolution thông thường, và overload constructor
std::initializer_list được ưu tiên
- Khởi tạo bằng ngoặc nhọn không cho phép narrowing conversion khi khởi tạo bằng một phần tử
struct A {
const int x;
};
A b{4}; // có thể
A c{4.0f}; // lỗi narrowing conversion
Khởi tạo bằng ngoặc tròn permissive hơn
- Khởi tạo bằng ngoặc tròn như
T t(a, b, c); gọi direct-non-list-initialization, có nét giống direct-list-initialization nhưng mang các quy tắc khác
T t(); không phải là tạo đối tượng, mà được diễn giải là khai báo hàm không có đối số và có kiểu trả về là T, tức vấn đề most vexing parse
- Từ C++20, aggregate cũng có thể dùng khởi tạo bằng ngoặc tròn
- Aggregate initialization dựa trên ngoặc tròn hoạt động khác với khởi tạo dựa trên ngoặc nhọn
- Cho phép narrowing conversion
- Các phần tử còn lại được value-initialized trực tiếp, không phải khởi tạo bằng danh sách rỗng
- Không kéo dài lifetime của đối tượng tạm thời được bind vào tham chiếu
struct T {
const int& r;
};
T t(42);
- Trong đoạn mã trên,
t.r trở thành dangling reference, và nếu đọc nó thì là undefined behaviour
std::initializer_list, copy constructor và elision
- Khởi tạo bằng ngoặc tròn có thể gọi copy constructor của
T ngay cả khi có overload constructor std::initializer_list<T>
- Ngược lại, trong khởi tạo bằng ngoặc nhọn, constructor
std::initializer_list có thể được ưu tiên
struct T {
T(std::initializer_list<T>) {
std::cout << "list" << std::endl;
}
T(const T&) {
std::cout << "copy" << std::endl;
}
};
T t{}; // list
T s{t}; // list
T r(t); // copy
T q(T{}); // list, không copy
- Việc không có copy trong
T q(T{}); là do quy định rằng trong direct- và copy-initialization, nếu initializer là prvalue kiểu T, đối tượng sẽ được khởi tạo trực tiếp bằng biểu thức đó
- Hành vi này thường được biết đến là copy elision, nhưng tiêu chuẩn không gọi tường minh nó là elision
- Việc elision tương tự có thể xảy ra trong list-initialization hay không chưa được tiêu chuẩn giải thích đầy đủ, và CWG issue 2311 có liên quan
- GCC và Clang thực hiện elision trong hầu hết trường hợp
- Nếu có overload constructor
std::initializer_list<T>, GCC dùng constructor đó thay vì elision
Thứ tự đánh giá và kết luận thực tiễn
- Thứ tự đánh giá các phần tử trong danh sách khởi tạo bằng ngoặc tròn không được đảm bảo
- Danh sách khởi tạo bằng ngoặc nhọn đánh giá các phần tử nghiêm ngặt từ trái sang phải
- Cũng có các quy tắc đặc biệt như khởi tạo biến tĩnh và constant initialization, nhưng chỉ riêng khởi tạo đối tượng thông thường đã đủ phức tạp
- Trong thực tế, thay vì dựa vào constructor mặc định ngầm định và các quy tắc khởi tạo, sẽ an toàn hơn khi viết constructor trực tiếp để nêu rõ ý định khởi tạo
1 bình luận
Ý kiến trên Hacker News
Mô tả rằng khởi tạo giá trị cho
Tsẽ cho kết quả là 0 là đúng, lúc đầu tôi đã bỏ sót nhưng tác giả thực sự đang khởi tạo giá trị choxNhìn chung, các quy tắc khởi tạo của C++ đã trở nên khó hiểu và có những góc cạnh phi lý sau 40 năm mở rộng và bảo trì, nhưng tôi nghĩ khoảng 99,9% trường hợp nó vẫn hoạt động như mong đợi
Tôi cho rằng cải tiến lớn là biến khởi tạo mặc định thành thứ phải được chỉ rõ tường minh, còn lại thì luôn khởi tạo giá trị. Khi muốn tránh chi phí điền 0 cho mảng lớn, tốt hơn là chỉ rõ bằng cú pháp như
std::array = void;Có vẻ đây là một trường hợp cố tỏ ra quá thông minh rồi lại than phiền rằng khó kiểm soát
Toàn bộ cách tiếp cận đã sai. Đừng đưa tham chiếu const vào struct, nếu thực sự cần thì dùng
std::reference_wrappersẽ đúng hơnCâu trả lời có hơi cộc lốc, nhưng điểm chính là kết luận của bài viết hoàn toàn sai. Đừng tự viết constructor trực tiếp mà hãy theo quy tắc 5/3/0, và nếu phải giữ tham chiếu const thì cần kiểm tra xem có đang truyền một temporary rvalue hay không. Đây không phải vấn đề đáng sợ đến thế
Ngay cả người chỉ học qua tutorial cơ bản cũng đơn giản là tránh các kịch bản gượng ép như thế này
std::reference_wrapper, và cũng chưa từng thấy nó trong nhiều codebase C++ mà tôi từng làm việcCó thể nó xuất hiện đâu đó trong những đoạn template sâu và phức tạp, nên dù nhận xét đó có thể đúng thì theo kinh nghiệm của tôi, đây không phải là kiến thức thường thức
Có thể đọc nguyên tác
I Have No Mouth, and I Must Scream (1967)tại đây: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...Gia đình Martin H Greenberg đang ở nhà tôi, và ông ấy gọi tìm Martin. Tôi thích Sci-Fi nên cũng đã đọc vài cuốn sách của ông, nhưng sau khi nghe tên thì ký ức về cuộc trò chuyện gần như mờ đi. Cuối cùng tôi đánh thức Martin dậy và đưa điện thoại cho ông ấy, rồi sau đó rất khó ngủ lại
Tôi ngạc nhiên là chưa có bình luận mỉa mai nào về chỗ
unlesscòn được in đậm nữaCó cú pháp đẹp như
T t{v0};vàT t{v0, v1};, nhưng khởi tạo với 1 phần tử lại không hoạt động ổn định như trường hợp có từ 2 phần tử trở lênKhi ngôn ngữ đang đi theo hướng giúp việc tạo struct bằng parameter pack dễ hơn, và còn hỗ trợ kiểu gần giống mảng độ dài biến đổi có thể khởi tạo như thế này, thì sự khác biệt đó thật kỳ quặc. Tất nhiên kiểu đó cũng có thể là template
Bạn có thể tự viết constructor, hoặc khởi tạo tuple hay mảng chỉ với một phần tử được cung cấp, nhưng trong vài trường hợp đặc biệt, constructor sai có thể bị gọi. Tôi phát hiện ra điều này khi danh sách khởi tạo của C++11 mới xuất hiện và đã nghĩ nó thật điên rồ
Muốn xem thêm sự quái dị của C++ thì tôi khuyên đọc C++ FQA: https://yosefk.com/c++fqa/
Giờ nó đã cũ khoảng 15 năm, nhưng vì C++ hầu như không loại bỏ các tính năng hay hành vi cũ nên đồng thời nó cũng không lỗi thời
Giao diện blog thật sự rất đẹp. Rõ ràng là lấy cảm hứng từ máy tính thời DEC, nhưng vẫn gọn gàng và tối giản nên cảm thấy mới mẻ
Kinh khủng. Đây là một trong những phần đáng sợ của C++. Càng đáng tiếc hơn vì đây là ngôn ngữ có nhiều tính năng hay và có những người rất giỏi đang làm việc với nó
Tôi hy vọng những thứ như C++ syntax 2 của Herb sẽ biến nó thành một ngôn ngữ mà người bình thường như tôi cũng dùng được
Các ví dụ trong bài được dựng lên để cố tình chạm vào những trường hợp góc cạnh của tính năng ngôn ngữ tự sinh ra các special member function ngoại lệ mà lập trình viên đã chọn không tự viết
C++ có mục tiêu thiết kế là chỉ trả chi phí cho những gì mình dùng, và quy tắc 3/quy tắc 5 là kiến thức nền tảng ở mức nhập môn C++. Việc các special member function này chỉ được compiler tạo ra trong những điều kiện nhất định, nên nếu không khớp điều kiện thì sẽ không tự sinh, cũng là kiến thức cơ bản
Cuối cùng, tôi không hiểu việc cần tự thiết lập constructor sẽ dùng lại là điều gì quá vô lý. Việc phải khởi tạo khi tạo instance cũng chẳng có gì đáng ngạc nhiên. Ngoài thực tế, mọi người vẫn làm việc theo cách này mà không gây náo động gì lớn
Đọc cái này xong thấy chóng mặt. Nó làm tôi nhớ lại thời mình cố hiểu constructor Java và khởi tạo đối tượng
Ít nhất theo kinh nghiệm của tôi cho đến nay, lựa chọn không có constructor đặc biệt như Go và Rust giúp đơn giản hóa rất nhiều thứ
Không biết có ai sau khi chuyển sang ngôn ngữ không có constructor lại thấy nhớ constructor không
Dĩ nhiên vẫn có thể dùng
ptr::writevớiMaybeUninitđể tự ghi từng field một, nhưng cách đó không thân thiện lắm và struct phải cho phép điều này một cách tường minhRốt cuộc điều đó có nghĩa là phải khai báo và định nghĩa mọi constructor một cách tường minh, mà C++ ngay từ đầu cũng đã cho lựa chọn đó. Việc tự động sinh special member function là tính năng chỉ được thêm vào để hoạt động trong một số trường hợp rất cụ thể dành cho những ai muốn tránh mã lặp
Nếu muốn thì cứ tự thêm constructor và sống bình thường thôi. Than phiền rằng special member function khiến việc đơn giản trở nên phức tạp cũng hơi giống như phàn nàn rằng tiếng Anh không đơn giản chỉ vì trong từ điển có những từ khó
Theo như tôi hiểu thì constructor của Rust về cơ bản không giống Java sao?
https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
Tôi tự hỏi có công cụ C++ nào có thể thêm vào hoặc hiển thị toàn bộ hành vi ngầm định diễn ra phía sau không
Ví dụ như công cụ cho thấy các constructor được tự động thêm vào, copy constructor ngầm định, và những thứ bất ngờ khác
cppinsightsVới trường hợp
T::T() = default;, người ta sẽ kỳ vọng kết quả in ra là 0, nhưng ví dụ cho ra giá trị rác thật ra cũng không lạ đến thếLiên kết liên quan: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
Vì nếu làm khác đi thì bất kỳ người dùng thư viện nào cũng có thể thay đổi hành vi của thư viện