- การเริ่มต้นออบเจ็กต์ใน C++ แม้จะใช้ไวยากรณ์ที่คล้ายกันอย่าง
T t;, T t{};, T t{...} ก็ยังแยกเป็น default/value/list/aggregate initialization ซึ่งอาจทำให้สถานะของสมาชิกจริงแตกต่างกันได้
- ตำแหน่งที่ประกาศ
= default ให้คอนสตรัคเตอร์เริ่มต้น สามารถเปลี่ยนได้ว่าจะมี zero-initialization หรือไม่ และถ้านิยามไว้นอกคลาส สมาชิกอาจคงเป็นค่าที่ยังไม่ถูกกำหนด
- แม้ชนิดข้อมูลที่มีสมาชิก
const int จะดูเหมือนว่าคอนสตรัคเตอร์เริ่มต้นน่าจะถูกลบ แต่ถ้าเป็น aggregate แล้ว A a{}; อาจถูกเริ่มต้นผ่าน aggregate initialization และได้ค่า 0
- aggregate initialization แบบใช้วงเล็บของ C++20 ยืดหยุ่นกว่าการใช้วงเล็บปีกกา จึงทำให้เกิดความต่างอย่าง narrowing conversion หรือ dangling reference ได้
- ในการใช้งานจริง แทนที่จะพึ่งพาการผสมกันของคอนสตรัคเตอร์แบบปริยายกับกฎการเริ่มต้นค่า การเขียน คอนสตรัคเตอร์โดยตรง เพื่อแสดงเจตนาของการเริ่มต้นค่ามักช่วยหลีกเลี่ยงข้อยกเว้นได้ง่ายกว่า
จุดที่ไวยากรณ์การเริ่มต้นค่าใน C++ แตกแขนง
T t; จะทำ default-initialization
- ถ้า
T เป็นชนิดคลาสและมีคอนสตรัคเตอร์เริ่มต้น ก็จะเรียกคอนสตรัคเตอร์นั้น
- ถ้า
T เป็นอาร์เรย์ จะทำ default-initialize ให้แต่ละสมาชิก
- นอกนั้นจะไม่ทำอะไรเลย
T t{}; ดูเหมือนเป็น value-initialization แต่เพราะเป็นไวยากรณ์แบบวงเล็บปีกกา จึงถูกจัดการเป็น list-initialization ก่อน
- แม้ตัวอย่างง่าย ๆ ก็เห็นความต่างได้ทันที
int x{}; ถูก value-initialized จึงเริ่มต้นเป็น 0
int y; ถูก default-initialized จึงยังไม่ถูกกำหนดค่า
Pair p; และ Pair q{}; ที่มีคอนสตรัคเตอร์เริ่มต้นจะเรียกคอนสตรัคเตอร์
SimplePair r; ที่มีแต่สมาชิกจะถูก default-initialized ทำให้สมาชิกอาจยังไม่ถูกกำหนดค่า
ผลลัพธ์ที่เปลี่ยนไปตามตำแหน่งประกาศคอนสตรัคเตอร์เริ่มต้น
- ถ้าผู้ใช้ไม่ได้ประกาศคอนสตรัคเตอร์ คอมไพเลอร์จะประกาศคอนสตรัคเตอร์เริ่มต้นแบบ implicitly-declared ให้
- ถ้าเขียน
T() = default; ในการประกาศครั้งแรกภายในคลาส มาตรฐานจะถือว่าแทบไม่ต่างจากคอนสตรัคเตอร์ที่ถูกประกาศแบบปริยาย
- ถ้าคอนสตรัคเตอร์เริ่มต้นที่ถูกประกาศแบบปริยายหรือถูก defaulted อย่างชัดเจนไม่ได้ถูกลบ คอมไพเลอร์จะจัดให้มี implicitly-defined default constructor
- ในเชิง implementation มันควรเทียบเท่ากับ
T() {} ที่มีบอดีว่างและไม่มี member initializer list
- ในโค้ดต่อไปนี้
t.x จะเป็น 0
struct T {
int x;
T() = default;
};
T t{};
std::cout << t.x << std::endl;
- เพราะ
t ถูก value-initialized และคอนสตรัคเตอร์เริ่มต้นของ T ไม่ใช่ user-provided จึงเกิด zero-initialization ก่อน แล้วค่อยเรียกคอนสตรัคเตอร์เริ่มต้น
= default นอกคลาสและคอนสตรัคเตอร์แบบ user-provided
- แม้จะเป็น
= default เหมือนกัน แต่ถ้านิยามไว้นอกคลาส จะกลายเป็นคอนสตรัคเตอร์แบบ user-provided
struct T {
int x;
T();
};
T::T() = default;
T t{};
std::cout << t.x << std::endl;
- ในกรณีนี้
T::T() = default; ไม่ได้เป็นการ default ที่การประกาศครั้งแรก แต่เป็นคอนสตรัคเตอร์ที่ถูก นิยาม แบบ defaulted นอกคลาส
- กฎของ value-initialization ระบุว่า ถ้ามีคอนสตรัคเตอร์เริ่มต้นที่เป็น user-provided หรือ deleted จะทำ default-initialization แทน
- ดังนั้นตัวอย่างข้างบนจึงเรียกแค่คอนสตรัคเตอร์เริ่มต้นโดยไม่มี zero-initialization และเพราะคอนสตรัคเตอร์นั้นไม่ทำอะไรเลย
t.x จึงกลายเป็นค่าขยะ
กรณีที่คอนสตรัคเตอร์เริ่มต้นถูกลบ
- ถ้าคอมไพเลอร์ไม่สามารถสร้างคอนสตรัคเตอร์เริ่มต้นที่สมเหตุสมผลได้ คอนสตรัคเตอร์เริ่มต้นที่ถูกประกาศแบบปริยายอาจถูกนิยามเป็น deleted
- เงื่อนไขที่พบบ่อยมีดังนี้
- มีสมาชิกอ้างอิงแบบ non-static
- มีสมาชิกแบบ non-static หรือ base class ที่ไม่เป็น abstract ซึ่งไม่เหมาะกับการ default-construct หรือ destruct
- มีสมาชิก
const แบบ non-static ที่ไม่มี default member initializer และสมาชิกนั้นไม่ใช่ const-default-constructible
- ถ้าผู้ใช้จัดเตรียมคอนสตรัคเตอร์เอง คอมไพเลอร์จะไม่พยายามนิยามคอนสตรัคเตอร์เริ่มต้นแบบปริยาย และจะไม่สร้างคอนสตรัคเตอร์ที่เป็น deleted ด้วย
- แม้มีเงื่อนไขเหล่านี้ กฎการเริ่มต้นค่าใน C++ โดยรวมก็ยังยืดหยุ่นมากอยู่ดี
จังหวะที่ aggregate initialization เข้ามาเกี่ยวข้อง
struct A { const int x; }; A a{}; ดูเหมือนควรคอมไพล์ไม่ผ่านเพราะคอนสตรัคเตอร์เริ่มต้นน่าจะถูกลบ แต่จริง ๆ แล้วคอมไพล์ได้ และ a.x จะเป็น 0
- จุดสำคัญคือ
A เป็น aggregate
- aggregate อาจเป็นอาร์เรย์ หรือเป็นคลาสที่มีเงื่อนไขดังนี้
- ไม่มีคอนสตรัคเตอร์ที่ผู้ใช้ประกาศหรือสืบทอดมา
- ไม่มี direct non-static data member ที่เป็น
private/protected
- ไม่มี direct base class ที่เป็น
private/protected
- ไม่มี virtual function หรือ virtual base class
- เมื่อทำ list-initialization กับ aggregate จะใช้ aggregate initialization ยกเว้นบางกรณีพิเศษ
- แต่ละสมาชิกใน initializer list จะใช้เริ่มต้นแต่ละองค์ประกอบของ aggregate ตามลำดับ
- ถ้าจำนวนสมาชิกในรายการไม่พอ องค์ประกอบที่เหลือจะใช้ default member initializer ถ้ามี
- ถ้าไม่มี default member initializer และไม่ใช่ reference ก็จะถูก copy-initialized ด้วย initializer list ว่าง
- ดังนั้น
A a{}; จึงไม่ใช่การเรียกคอนสตรัคเตอร์ แต่เป็นกรณีที่ a.x ถูก copy-list-initialized ด้วยรายการว่าง แล้วผ่าน value-initialization จนกลายเป็น zero-initialization
กฎเพิ่มเติมของการเริ่มต้นค่าแบบวงเล็บปีกกา
- ไวยากรณ์แบบวงเล็บปีกกาอย่าง
T t{...} และ T t = {...} โดยมากคือ list-initialization
T t{...} คือ direct-list-initialization
T t = {...} คือ copy-list-initialization
- ถ้าเป็นชนิดคลาสที่ไม่ใช่ aggregate มี initializer list ว่าง และมีคอนสตรัคเตอร์เริ่มต้น จะทำ value-initialization
- นอกนั้นจะพิจารณาคอนสตรัคเตอร์ผ่าน overload resolution ตามปกติ โดย overload ของ
std::initializer_list จะมีสิทธิ์ก่อน
- การเริ่มต้นแบบวงเล็บปีกกาไม่อนุญาต narrowing conversion เมื่อเริ่มต้นด้วยสมาชิกเดียว
struct A {
const int x;
};
A b{4}; // ได้
A c{4.0f}; // error จาก narrowing conversion
การเริ่มต้นด้วยวงเล็บธรรมดายืดหยุ่นกว่า
- การเริ่มต้นด้วยวงเล็บธรรมดาอย่าง
T t(a, b, c); จะเรียก direct-non-list-initialization ซึ่งคล้ายกับ direct-list-initialization แต่ใช้กฎต่างกัน
T t(); ไม่ได้หมายถึงการสร้างออบเจ็กต์ แต่จะถูกตีความเป็นการประกาศฟังก์ชันที่ไม่มีอาร์กิวเมนต์และคืนค่าเป็น T ซึ่งเป็นปัญหา most vexing parse
- ตั้งแต่ C++20 เป็นต้นมา aggregate ก็สามารถใช้การเริ่มต้นด้วยวงเล็บธรรมดาได้
- aggregate initialization แบบวงเล็บธรรมดาทำงานไม่เหมือนแบบวงเล็บปีกกา
- อนุญาต narrowing conversion
- องค์ประกอบที่เหลือจะไม่ถูกเริ่มต้นด้วยรายการว่าง แต่จะถูก value-initialized โดยตรง
- จะไม่ขยายอายุของ temporary object ที่ถูก bind กับ reference
struct T {
const int& r;
};
T t(42);
- ในโค้ดข้างบน
t.r จะเป็น dangling reference และหากอ่านค่าจะเป็น undefined behaviour
std::initializer_list, copy constructor และ elision
- การเริ่มต้นด้วยวงเล็บธรรมดาอาจเรียก copy constructor ของ
T ได้ แม้ว่าจะมี constructor overload แบบ std::initializer_list<T> อยู่ก็ตาม
- ในทางกลับกัน การเริ่มต้นแบบวงเล็บปีกกาอาจเลือก constructor ของ
std::initializer_list ก่อน
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, ไม่มี copy
- ที่
T q(T{}); ไม่เกิดการคัดลอก เพราะใน direct-initialization และ copy-initialization หาก initializer เป็น prvalue ชนิด T ออบเจ็กต์จะถูกเริ่มต้นโดยตรงจากนิพจน์นั้น
- พฤติกรรมนี้มักเรียกว่า copy elision แต่ในมาตรฐานไม่ได้เรียกสิ่งนี้ว่า elision อย่างชัดเจน
- สำหรับ list-initialization มาตรฐานยังอธิบายไม่ชัดว่าทำ elision แบบเดียวกันได้หรือไม่ โดยมี CWG issue 2311 ที่เกี่ยวข้อง
- GCC และ Clang ทำ elision ในกรณีส่วนใหญ่
- ถ้ามี constructor overload แบบ
std::initializer_list<T> อยู่ GCC จะเลือกใช้ constructor นั้นแทนการทำ elision
ลำดับการประเมินผลและข้อสรุปเชิงปฏิบัติ
- ลำดับการประเมินสมาชิกในรายการเริ่มต้นแบบวงเล็บธรรมดาไม่ได้รับการรับประกัน
- รายการเริ่มต้นแบบวงเล็บปีกกาจะประเมินสมาชิกอย่างเคร่งครัดจากซ้ายไปขวา
- ยังมีกฎพิเศษอย่างการเริ่มต้นตัวแปรแบบ static และ constant initialization อีก แต่แค่กฎของการเริ่มต้นออบเจ็กต์ทั่วไปก็ซับซ้อนมากพอแล้ว
- ในงานจริง การเขียน คอนสตรัคเตอร์โดยตรง เพื่อระบุเจตนาของการเริ่มต้นค่าให้ชัดเจน ปลอดภัยกว่าการพึ่งพาคอนสตรัคเตอร์เริ่มต้นแบบปริยายและกฎการเริ่มต้นค่าต่าง ๆ
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
คำอธิบายที่ว่าเมื่อ เริ่มต้นค่า ให้
Tแล้วผลลัพธ์จะเป็น 0 นั้นถูกต้อง และถึงตอนแรกจะพลาดไป แต่ผู้เขียนก็ทำการเริ่มต้นค่าให้xจริง ๆโดยรวมแล้ว กฎการเริ่มต้นค่าใน C++ ผ่านการขยายและซ่อมแซมมาตลอด 40 ปี จนมีมุมที่เข้าใจยากและไม่สมเหตุสมผลเกิดขึ้นบ้าง แต่ผมมองว่าประมาณ 99.9% มันทำงานอย่างที่คาดไว้
ผมคิดว่าการปรับปรุงใหญ่คือทำให้ การเริ่มต้นค่าแบบดีฟอลต์ ต้องระบุอย่างชัดเจน และนอกนั้นให้เป็นการเริ่มต้นค่าเสมอ ถ้าอยากเลี่ยงต้นทุนการเติม 0 ให้กับอาร์เรย์ขนาดใหญ่ ก็ควรระบุด้วยไวยากรณ์อย่าง
std::array = void;เท่านั้นจะดีกว่าดูเหมือนเป็นกรณีที่พยายามฉลาดเกินไปแล้วบ่นว่ารับมือยาก
แนวทางทั้งหมดผิดตั้งแต่ต้น อย่าใส่ const reference ไว้ใน struct และถ้าจำเป็นจริง ๆ ควรใช้
std::reference_wrapperคำตอบอาจจะห้วนไปหน่อย แต่ประเด็นสำคัญคือข้อสรุปของบทความผิดทั้งหมด อย่าเขียนคอนสตรักเตอร์เอง ให้ทำตาม กฎ 5/3/0 และถ้าจำเป็นต้องถือ const reference ไว้ ก็ต้องตรวจสอบว่ามีการส่งอ็อบเจ็กต์ชั่วคราวแบบ rvalue เข้ามาหรือไม่ เรื่องนี้ไม่ได้เป็นปัญหาน่ากลัวขนาดนั้น
แม้แต่คนที่ผ่านแค่บทเรียนพื้นฐานมาก็จะหลีกเลี่ยงสถานการณ์ที่ประดิษฐ์ขึ้นแบบนี้ไปเลย
std::reference_wrapperเลย และก็ไม่เคยเห็นในโค้ดเบส C++ หลายแห่งที่เคยทำงานด้วยมันอาจถูกใช้ที่ไหนสักแห่งในโค้ดเทมเพลตที่ลึกและซับซ้อน และที่พูดอาจถูกก็ได้ แต่จากประสบการณ์ของผม มันไม่ใช่ ความรู้ทั่วไป
ต้นฉบับของ
I Have No Mouth, and I Must Scream (1967)อ่านได้ที่นี่: https://talesofmytery.blogspot.com/2018/10/harlan-ellison-i-...ครอบครัวของ Martin H Greenberg มาพักที่บ้านเรา และเขากำลังตามหา Martin ผมชอบ Sci-Fi และเคยอ่านหนังสือของเขาหลายเล่ม พอได้ยินชื่อเขาแล้ว บทสนทนาหลังจากนั้นก็แทบจำได้ราง ๆ สุดท้ายผมปลุก Martin แล้วโอนสายให้ หลังจากนั้นก็นอนหลับยากมาก
แปลกใจที่ไม่มีคอมเมนต์ประชดตรงคำว่า
unlessที่ถึงขั้นทำตัวหนาไว้มีไวยากรณ์เท่ ๆ อย่าง
T t{v0};และT t{v0, v1};แต่ การเริ่มต้นค่าด้วยองค์ประกอบ 1 ตัว กลับทำงานได้ไม่เสถียรเหมือนกรณีที่มีองค์ประกอบตั้งแต่ 2 ตัวขึ้นไปในภาษาที่มุ่งไปทางทำให้สร้าง struct จาก parameter pack ได้ง่ายขึ้น และยังรองรับสิ่งคล้ายอาร์เรย์ความยาวแปรผันที่เริ่มต้นค่าได้แบบนี้ ความแตกต่างนี้ดูแปลก Type เองก็อาจเป็นเทมเพลตได้แน่นอน
คุณจะเขียนคอนสตรักเตอร์เองก็ได้ และเริ่มต้น tuple หรือ array โดยป้อนองค์ประกอบเดียวก็ได้ แต่ในกรณีพิเศษบางอย่าง คอนสตรักเตอร์ผิดตัว อาจถูกเรียก ตอน C++11 initialization list เพิ่งออกมา ผมเจอเรื่องนี้แล้วคิดว่ามันบ้าไปแล้ว
ถ้าอยากดูความประหลาดของ C++ เพิ่ม แนะนำ C++ FQA: https://yosefk.com/c++fqa/
ตอนนี้มันเก่าไปราว 15 ปีแล้ว แต่ C++ แทบไม่ลบฟีเจอร์หรือพฤติกรรมเก่าออก จึงยังไม่ล้าสมัยไปพร้อมกัน
ธีมบล็อกสวยมาก เห็นได้ชัดว่าได้แรงบันดาลใจจาก คอมพิวเตอร์ยุค DEC แต่ก็ยังสะอาดและมินิมอลจนดูสดใหม่
แย่มาก นี่เป็นหนึ่งในส่วนที่น่ากลัวของ C++ ยิ่งน่าเสียดายเพราะภาษานี้ก็มีฟีเจอร์เจ๋ง ๆ และมีคนเก่ง ๆ ทำงานอยู่กับมัน
หวังว่าสิ่งอย่าง C++ syntax 2 ของ Herb จะทำให้มันกลายเป็นภาษาที่คนธรรมดาอย่างผมก็ใช้ได้
ตัวอย่างในบทความถูกทำขึ้นเพื่อจงใจไปแตะ edge case ของความสามารถที่ภาษา生成 special member function ให้โดยอัตโนมัติเป็นข้อยกเว้น ทั้งที่โปรแกรมเมอร์เลือกจะไม่เขียนเอง
C++ มีเป้าหมายการออกแบบว่าใช้เท่าไรก็จ่ายเท่านั้น และ กฎ 3/กฎ 5 ก็เป็นความรู้พื้นฐานระดับเริ่มต้นของ C++ special member function เหล่านี้จะถูกคอมไพเลอร์สร้างขึ้นเฉพาะเมื่อมีเงื่อนไขบางอย่างเท่านั้น ดังนั้นถ้าเงื่อนไขไม่ตรง ก็ย่อมไม่ถูกสร้างอัตโนมัติ นี่ก็เป็นเรื่องพื้นฐานเช่นกัน
สุดท้ายแล้ว ผมไม่เห็นว่าการรู้ว่าต้องตั้งค่าคอนสตรักเตอร์ที่จะใช้เองนั้นเป็นเรื่องเหลวไหลตรงไหน การที่ต้องเริ่มต้นค่าเมื่อสร้างอินสแตนซ์ก็ไม่ใช่เรื่องน่าตกใจ ในโลกจริง ผู้คนก็ทำงานจริงด้วยวิธีนี้กันโดยไม่ได้โวยวายใหญ่โต
อ่านแล้วรู้สึกเวียนหัว นึกถึงตอนที่พยายามทำความเข้าใจ constructor ของ Java กับการเริ่มต้นค่าออบเจ็กต์
อย่างน้อยจากประสบการณ์ที่ผ่านมาจนถึงตอนนี้ การเลือกแบบที่ไม่มี constructor พิเศษ อย่าง Go และ Rust ทำให้อะไรหลายอย่างเรียบง่ายขึ้น
อยากรู้ว่ามีใครบ้างไหมที่ย้ายไปใช้ภาษาที่ไม่มี constructor แล้วคิดถึง constructor
แน่นอนว่าสามารถเขียนแต่ละฟิลด์ลงใน
MaybeUninitโดยตรงด้วยptr::writeได้ แต่ไม่สะดวกต่อการใช้งาน และ struct ต้องอนุญาตสิ่งนี้อย่างชัดเจนสุดท้ายก็หมายความว่าต้องประกาศและนิยาม constructor ทั้งหมดอย่างชัดเจน ซึ่ง C++ ก็มีตัวเลือกนั้นให้ตั้งแต่แรกอยู่แล้ว การสร้าง special member function อัตโนมัติเป็นฟีเจอร์ที่เพิ่มเข้ามาเพื่อให้ทำงานเฉพาะบางกรณีมาก ๆ สำหรับคนที่อยากเลี่ยงโค้ดซ้ำ ๆ เท่านั้น
ถ้าต้องการก็เพิ่ม constructor เอง แล้วใช้ชีวิตแบบปกติได้ การบ่นว่าเรื่องง่าย ๆ กลายเป็นซับซ้อนเพราะ special member function ก็คล้ายกับการบ่นว่าภาษาอังกฤษไม่เรียบง่ายเพราะในพจนานุกรมมีคำยาก ๆ
เท่าที่ผมเข้าใจ constructor ของ Rust โดยพื้นฐานก็คล้าย Java ไม่ใช่หรือ?
https://codefibershq.com/blog/golang-why-nil-is-not-always-n...
สงสัยว่ามีเครื่องมือ C++ ที่เพิ่มหรือแสดง พฤติกรรมโดยนัย ทั้งหมดที่เกิดขึ้นเบื้องหลังหรือไม่
เช่น เครื่องมือที่แสดง constructor ที่ถูกเพิ่มอัตโนมัติ, implicit copy constructor และสิ่งน่าประหลาดใจอื่น ๆ
cppinsightsน่าจะเป็นทางเลือกที่ดีที่สุดกรณี
T::T() = default;ตัวอย่างที่คาดว่า output จะเป็น 0 แต่กลับได้ค่า garbage นั้น จริง ๆ ก็ไม่ได้แปลกขนาดนั้นลิงก์ที่เกี่ยวข้อง: https://consteval.ca/2024/07/03/initialization/#:~:text=You%...
เพราะถ้าทำให้ต่างออกไป ผู้ใช้ไลบรารีทุกคนก็จะสามารถเปลี่ยนพฤติกรรมของไลบรารีได้