1 คะแนน โดย GN⁺ 18 시간 전 | 2 ความคิดเห็น | แชร์ทาง WhatsApp
  • ไลบรารีมาตรฐาน C++ ทำซ้ำรูปแบบเดิมมาตั้งแต่ C++11 คือเลิกใช้ดีไซน์ที่ผิดพลาดอย่างเป็นทางการ หรือปล่อยทิ้งไว้ข้างฟีเจอร์ทดแทนใหม่ ทำให้นักพัฒนาต้องรู้เองว่าเมื่อใดคือยุคของ “ชั้นที่ไม่ควรใช้”
  • ชั้นที่ถูกถอนอย่างเป็นทางการ มีรายการอย่าง std::auto_ptr, dynamic exception specification, อินเทอร์เฟซ garbage collection ของ C++11, std::aligned_storage ที่มาพร้อม paper สำหรับเลิกใช้หรือลบออก ขณะที่ std::function เองก็ถูกครอบด้วยกระแสการแทนที่ตลอด 15 ปีผ่าน std::move_only_function, std::copyable_function, std::function_ref
  • ชั้นที่ถูกหลีกเลี่ยงอย่างไม่เป็นทางการ มีทั้ง std::regex ที่ช้า, std::async ที่รอใน destructor จนกลายเป็นกับดัก deadlock, รวมถึง <iostream>, std::list, std::deque, std::vector<bool> ที่ยังอยู่ในมาตรฐาน แต่โค้ดโปรดักชันมักอ้อมไปใช้ทางอื่น
  • ปัญหาคอนเทนเนอร์ค่าเริ่มต้น เด่นชัดใน std::unordered_map, std::map, std::list โดย benchmark ภาระงานเดียวกันพบว่า P99 ของอิมพลีเมนต์แบบตรงไปตรงมาฝั่ง C++ อยู่ที่ 302,653 cycles ขณะที่ฝั่ง Rust แบบตรงไปตรงมาอยู่ที่ 5,177 cycles ต่างกัน 58 เท่า
  • การเลือกคง ABI เสถียร คือความต่างสำคัญที่ทำให้ค่าเริ่มต้นที่ผิดพลาดของ C++ ถูกเก็บไว้แทบถาวรใน std:: ต่างจากภาษาอื่นที่ลดความผิดพลาดด้วยการลบออก ใช้ edition หรือเปลี่ยนผ่าน major version

จุดเริ่มต้น: การตัดสิน std::function ว่าเป็น “legacy”

  • ตารางอ้างอิงด่วนของ Sandor Dargo สำหรับ std::copyable_function จัด std::function ไว้ในกลุ่ม “Legacy. Avoid in new code.”
  • std::function เข้ามาใน C++11 ส่วน wrapper ทดแทนตัวล่าสุดคือ std::copyable_function จะเข้าใน C++26 และข้อเสนอแนะของฟีเจอร์ใหม่ก็ใกล้เคียงกับ “อย่าใช้ตัวเดิม” มากกว่า “ใช้เมื่อคุณต้องการ callable object ที่ copy ได้”
  • const operator() ของ std::function มีข้อบกพร่องด้าน const-correctness เพราะสามารถเรียก callable object ที่เป็น non-const ได้ และไม่สามารถแก้โดยไม่ทำลาย ABI
  • เพื่อตอบสนองต่อข้อบกพร่องนี้ std::move_only_function จึงอยู่ในสายของ P0288R9 สำหรับ C++23, std::copyable_function อยู่ใน P2548R6 สำหรับ C++26 และ std::function_ref อยู่ใน P0792R14 สำหรับ C++26

ฟีเจอร์มาตรฐานที่ถูกย้อนกลับอย่างเป็นทางการ

  • std::auto_ptr ถูกเลิกใช้ใน C++11 เพราะความหมายแบบ copy-as-move ทำให้ generic code และ standard container พัง ก่อนถูกลบใน C++17 ผ่าน N4190 ซึ่ง paper เดียวกันยังลบ adapter ใน <functional> ของ C++98 และ std::random_shuffle ด้วย
  • std::random_shuffle ถูกแทนที่ด้วย std::shuffle เพราะพึ่งพา std::rand และ global state
  • dynamic exception specification แบบ throw(X, Y) ถูกเลิกใช้ใน C++11 และลบใน C++17 ด้วย P0003R5 ส่วน alias throw() ถูกลบใน C++20 ด้วย P1152
  • std::iterator ถูกประกาศเลิกใช้ใน C++17 ด้วย P0174R2 และกำลังถูกผลักดันให้ลบใน C++26 ด้วย P3365R1 โดยแนวทางทดแทนคือกำหนด typedef ทั้งห้าตัวเองโดยตรง
  • std::aligned_storage และ std::aligned_union เข้ามาใน C++11 ก่อนถูกประกาศเลิกใช้ใน C++23 ด้วย P1413R3 โดยปัญหาที่ถูกชี้คือ boilerplate แบบ typename ::type, การใช้ reinterpret_cast, undefined behavior เมื่อ Len == 0 และการไม่มี constexpr
  • std::not1, std::not2, unary_negate, binary_negate ถูกเลิกใช้ใน C++17 และลบใน C++20 โดยแทนที่ด้วย std::not_fn จาก P0005
  • ชุด std::declare_reachable ซึ่งเป็นอินเทอร์เฟซ garbage collection ของ C++11 ถูกลบใน C++23 ด้วย P2186R2 หลังจาก implementation หลักไม่เคยมี garbage collector จริงให้ใช้งาน
  • Concepts TS, Modules TS, Coroutines TS, Reflection TS, Executors TS และ Networking TS ก็ผ่านการออกแบบใหม่ การแทนที่ หรือการเลื่อนมาก่อนรวมเข้ามา โดย Reflection เปลี่ยนไปสู่ P2996 และ Executors เปลี่ยนไปสู่แนว sender/receiver ของ P2300

ฟีเจอร์ที่ยังอยู่ในมาตรฐาน แต่ภาคสนามหลีกเลี่ยง

  • std::regex เข้ามาใน C++11 แต่ P1844R1 บันทึกไว้ในระดับคณะกรรมการว่า “ประสิทธิภาพแย่มากเมื่อเทียบกับทางเลือกอื่นที่ใช้งานได้” โดยสายทดแทนคือ CTRE และ P1433R0 ส่วนภายนอกมาตรฐานมี Boost.Regex, RE2, PCRE2
  • std::async มีปัญหาที่ destructor ของ future ที่คืนกลับมาจะ block จนกว่างาน async จะเสร็จ และ N3679 ก็ได้บันทึกกับดัก deadlock ที่เกิดจากสิ่งนี้ไว้
  • <iostream> ช้า ผูกกับ locale ไม่ thread-safe ในงาน formatting และมี error message ที่ขึ้นชื่อว่าอ่านยาก แต่แม้หลังมี std::format ใน C++20 จาก P0645 และ std::print/std::println ใน C++23 จาก P2093 ก็ยังไม่ถูกประกาศเลิกใช้
  • std::list เป็นกรณีที่ Bjarne Stroustrup แสดงใน 2012 GoingNative keynote ว่าแม้แต่ภาระงานแทรกตรงกลาง std::vector ก็ยังชนะ และบทความต่อมา Are lists evil? ก็ให้คำตอบที่ใกล้เคียงกับ “ใช่”
  • std::deque เป็นรายการที่ issue สาธารณะของ Microsoft STL microsoft/STL#147 ระบุว่าขนาดบล็อกที่มาตรฐานบังคับนั้นเล็กเกินไป และต้องมีการยกเครื่องประสิทธิภาพครั้งใหญ่ในการทำ ABI break รอบถัดไป
  • std::valarray ถูกเพิ่มเข้ามาในปี 1998 ในฐานะ numerical container แต่การ optimize แบบ expression template ไม่เคยเกิดขึ้นจริง และจาก cppreference ดูเหมือน implementation ต่าง ๆ ก็ไม่ได้มีโค้ดพิเศษเหนือ container ทั่วไป
  • std::vector<bool> มีบทวิเคราะห์ตัวแทนคือ On vector<bool> ของ Howard Hinnant โดยแม้การเก็บข้อมูลแบบ bit-packed จะมีประโยชน์ แต่การตั้งชื่อให้เป็น specialization ของ std::vector ทำให้มันกลายเป็นกับดักที่ทำให้ generic code ทำงานผิดเมื่อ T = bool
  • volatile ถูกประกาศเลิกใช้ใน C++20 ด้วย P1152R4 สำหรับ composite operations และตำแหน่งพารามิเตอร์/คืนค่า ก่อนถูกย้อนกลับบางส่วนใน C++23 ด้วย P2327R1 และมีแผนย้อนกลับเพิ่มเติมใน C++26 ด้วย P2866R0

คอนเทนเนอร์ค่าเริ่มต้นที่แก้ไม่ได้เพราะ ABI

  • std::unordered_map ถูกจำกัดโดยข้อกำหนดเรื่อง bucket และ iterator stability ในสเปก C++11 จนแทบห้ามการใช้ open addressing และมีการยกตัวอย่างว่าโครงสร้าง Google SwissTable ให้ประสิทธิภาพเหนือ std::unordered_map ราว 3 เท่า
  • Folly F14, Boost unordered_flat_map, ankerl::unordered_dense ก็เป็นทางเลือกในทิศทางเดียวกัน ขณะที่ HashMap ของ Rust ใช้พอร์ตของ hashbrown SwissTable เป็นค่าเริ่มต้นใน standard library
  • std::map และ std::set เป็น red-black tree แบบ node-based จึงต้อง heap allocation ต่อโหนดและต้อง pointer chasing ทุกครั้งที่ไล่ traversal ส่วน Abseil btree_map และ Rust BTreeMap ใช้ B-tree จึงหลีกเลี่ยงปัญหาเดียวกันได้
  • C++23 เพิ่ม std::flat_map และ std::flat_set ผ่าน P0429R9 แต่ก็ยังเปลี่ยนดีไซน์พื้นฐานของ std::unordered_map, std::map, std::list เองไม่ได้
  • multi-symbol order book benchmark เปรียบเทียบ std::unordered_map+std::map+std::list ของ C++ กับ HashMap+BTreeMap+VecDeque ของ Rust ภายใต้ภาระงานเดียวกัน seed เดียวกัน และคอร์ที่แยกเหมือนกัน
อิมพลีเมนต์ P99 cycles
C++ แบบตรงไปตรงมา (unordered_map + map + list) 302,653
C++ step 1 (flat_hash_map + map + deque) 9,951
C++ step 2 (flat_hash_map + btree_map + deque) 9,114
C++ step 3 (flat_hash_map + btree_map + vector) 4,268
Rust แบบตรงไปตรงมา (HashMap + BTreeMap + VecDeque) 5,177
  • การเปลี่ยนจาก std::list ไป std::vector เพียงอย่างเดียวให้ผลราว 70 เท่า, การเปลี่ยนจาก std::unordered_map ไป flat_hash_map ให้ผล 3–5 เท่า, ส่วนการเปลี่ยนจาก std::map ไป btree_map ให้ผล 1.09 เท่าและอยู่ในระดับ noise
  • จุดสำคัญของการเปรียบเทียบไม่ใช่ว่า Rust เร็วกว่า C++ โดยตัวภาษาเอง 58 เท่า แต่คือ standard library ของ Rust เลือกค่าเริ่มต้นที่ถูกต้อง ขณะที่ standard library ของ C++ ไม่สามารถแก้ค่าเริ่มต้นทั้งสามตัวได้เพราะ ABI

ปัญหา Vasa และการสะสมฟีเจอร์

  • เอกสาร WG21 ปี 2018 ของ Bjarne Stroustrup ชื่อ P0977R0 “Remember the Vasa!” ยกเหตุเรือรบสวีเดน Vasa ที่อับปางในปี 1628 มาเป็นอุปมา โดยวินิจฉัยว่าคณะกรรมการมี “พ่อครัวราว 150 คน” และฟีเจอร์แต่ละตัวไม่ได้ถูกพิจารณาผลกระทบต่อทั้งระบบอย่างเพียงพอ
  • std::simd ถูกยกเป็นตัวอย่างเด่นของรูปแบบเดียวกันในบทความ std::simd Is a Solution to the Wrong Problem โดยเป็นฟีเจอร์ที่ Matthias Kretz เริ่มจากไลบรารี Vc แล้วผ่าน P0214, Parallelism TS 2, P1928 ก่อนเข้ามาใน C++26
  • ตอน std::simd เข้ามาในมาตรฐาน ภายนอกมาตรฐานมีทั้ง Google Highway, ISPC, EVE, xsimd, SIMDe อยู่แล้ว และ auto-vectorizer ของ GCC กับ Clang ก็พัฒนาขึ้นจนมีการอ้างผลว่าลูป scalar ที่คอมไพล์ด้วย -O3 -march=native ทำได้ดีกว่า std::simd
  • std::simd มีปัญหาว่าคอมไพล์ช้ากว่า scalar code ที่เทียบเท่ากัน 10 เท่า, ช้ากว่า auto-vectorizer ที่ตั้งใจจะมาแทน และไม่สามารถแสดงเวกเตอร์แบบ scalable-width ของ ARM SVE รวมถึง runtime dispatch ได้
  • ทั้ง libstdc++, libc++, และ MSVC STL ถูกดูแลโดยทีมวิศวกรขนาดเลขหลักเดียว และทุกฟีเจอร์ใหม่ในมาตรฐานก็เพิ่มทั้ง test matrix, conformity bug, ปฏิสัมพันธ์ระหว่างฟีเจอร์ และรายการบั๊กที่ผู้ดูแลรุ่นถัดไปต้องรับช่วง
  • std::regex แบกปัญหาที่รู้กันมา 15 ปี, std::deque มี issue ว่าต้องออกแบบใหม่, และ C++20 modules ก็ถูกอธิบายว่ายังไม่ทำงานได้เรียบร้อยครบทั้งสาม implementation แม้ผ่านการมาตรฐานมาแล้ว 6 ปี
  • ความรู้เชิงปฏิบัติจริงของมาตรฐาน C++ ยุคใหม่จึงกระจุกอยู่กับผู้เชี่ยวชาญเต็มเวลาจำนวนน้อยที่รู้ทั้งช่วงเวลาของชั้นที่ผิดพลาด, วิธีอ้อมด้วยไลบรารี third-party, ความต่างของ standard library ทั้งสาม implementation และช่องว่างระหว่างทฤษฎีกับงานจริง

ความต่างจากภาษาอื่น: ไม่ใช่เรื่องทำพลาดหรือไม่ แต่คืออัตราการเก็บไว้

  • Python ลบโมดูลใน standard library มากกว่า 20 ตัวผ่าน PEP 594, ลบ distutils ใน Python 3.12 ผ่าน PEP 632 และ PEP 387 ก็ระบุสิทธิในการย่นวงจรเลิกใช้สำหรับฟีเจอร์ที่เสี่ยงหรือเสียหาย
  • Java จัดการ Applet API ด้วยเส้นทาง 8 ปี ตั้งแต่ประกาศเลิกใช้ใน Java 9 ไปจนถึงจะลบออกใน Java 17 และลบจริงใน JEP 504 ขณะที่ Nashorn ถูกลบใน Java 15 ผ่าน JEP 372
  • Java SecurityManager ถูกทำให้เป็น deprecated for removal ใน JEP 411 และถูกปิดถาวรด้วย JEP 486 ส่วน JEP 398 กล่าวถึงเส้นทางการลบ Applet API
  • Rust ให้เลือก edition 2015, 2018, 2021, 2024 แบบ opt-in ราย crate ใน Cargo.toml และมีการแทนที่ mem::uninitialized ด้วย MaybeUninit, std::error::Error::description ด้วย source, และ macro try! ด้วยโอเปอเรเตอร์ ?
  • C# ยอมรับการเปลี่ยนผ่านระดับ major version จาก .NET Framework ไป .NET Core พร้อมปล่อยของอย่าง BinaryFormatter, AppDomains, Remoting, Code Access Security, WCF server, WebForms ทิ้งไป
  • JavaScript แทบไม่ลบอะไรเพราะข้อจำกัดด้าน web compatibility แต่ cancelable promises ถูกถอนใน Stage 1, SIMD.js ถูกทิ้งไปทาง WebAssembly SIMD และ Go ก็เลือกเพียงประกาศ io/ioutil ว่าเลิกใช้เพราะสัญญาความเข้ากันได้ของ Go 1
  • ความต่างของ C++ ไม่ใช่ว่ามันเคยพลาดหรือไม่ แต่คืออัตราการเก็บรายการอย่าง std::regex, std::unordered_map, std::vector<bool>, std::valarray, และข้อบกพร่อง const-correctness ของ std::function ไว้แทบทั้งหมด

ABI เสถียรสร้างการคงอยู่ถาวร

  • P1863R1 “ABI - Now or Never” เป็นกระแสที่ถามว่าควรยอมทำ ABI break ใน C++23 หรือเลือก ABI เสถียรถาวร และในทางปฏิบัติคณะกรรมการก็เลือกอย่างหลัง
  • การเลือกนี้ทำให้การแก้ std::regex, การเปลี่ยน std::unordered_map ไปสู่ open addressing และการเปลี่ยนโครงสร้างของ std::list, std::map, std::deque กลายเป็นเรื่องยาก
  • ABI ของไลบรารีมาตรฐาน C++ ถูกบังคับโดย dynamic linker เพราะ object ที่คอมไพล์ด้วย libstdc++ รุ่นหนึ่งต้องลิงก์กับ object จากอีกรุ่นได้ ดังนั้นรายละเอียดอย่าง layout ของ std::string และองค์ประกอบของ std::regex_traits จึงถูกตรึงไว้ในไบนารีที่แจกจ่าย
  • ข้อจำกัดนี้ถูกทำให้เป็นรูปธรรมในเอกสารอย่าง libstdc++ ABI policy และ Itanium C++ ABI
  • ผู้ใช้ Python เลือก python==3.12, ผู้ใช้ Rust เลือก edition ใน Cargo.toml, ผู้ใช้ Java เลือกเวอร์ชัน JDK, ผู้ใช้ C# เลือก TFM อย่าง net6.0 หรือ net8.0 แต่ C++ ไม่มี Cargo.toml สำหรับ std::
  • -std=c++26 เลือกได้แค่ว่าจะใช้ header และกฎของภาษาแบบไหน แต่ไม่ได้ให้ std::string แบบอื่นหรือ std::unordered_map ที่ออกแบบใหม่
  • ดังนั้นไลบรารีมาตรฐาน C++ ที่ถูกส่งขึ้นโปรดักชันในปี 2026 จึงยังคงต้องแบกรับค่าเริ่มต้นที่ผิดพลาดซึ่งคณะกรรมการรับเข้ามาตั้งแต่ปี 1998 ต่อไป ทั้งด้วยดีไซน์และด้วยแรงบังคับของระบบ
  • codebase C++ สมัยใหม่ของ tier-one trading firm, เสิร์ชเอนจิน และเบราว์เซอร์ ต่างพึ่งพาไลบรารีนอกมาตรฐานอย่าง Boost, Abseil, Folly, EASTL, Chromium //base, คอนเทนเนอร์ที่เขียนเอง, custom allocator, CTRE, Outcome และไลบรารี coroutine อย่างหนัก

2 ความคิดเห็น

 
dieafterwork 3 시간 전

ต้นฉบับค่อนข้างยาวมาก แต่พออ่านจนจบแล้วก็รู้สึกได้พอสมควรว่าออกแนวศรัทธาใน Rust อยู่เหมือนกัน
อย่างไรก็ตามก็ได้รู้ข้อมูลที่ไม่เคยรู้มาก่อนเยอะเลย ขอบคุณสำหรับบทความดี ๆ ครับ

 
ความเห็นจาก Lobste.rs
  • พอนึกดูว่าระบบนิเวศ Rust เคยมี churn คล้ายกันไหม ก็ดูเหมือนจะมีเรื่องใหญ่ ๆ แค่ไม่กี่อย่าง
    ตอน Leakpocalypse มีข้อสรุปว่าไม่สามารถพึ่งได้ว่า destructor ของ Drop จะถูกรันเสมอเพื่อรักษา safety invariant และในทางปฏิบัติก็แทบไม่มีการเปลี่ยน API เลย นอกจากการลบ std::thread::scoped เท่านั้น หลังจากนั้นก็มีตัวแทนที่ทำแบบเดียวกันได้อย่าง sound เกิดขึ้น
    std::mem::uninitialized ถูก deprecated และตอนนี้ถือว่า unsound แล้ว ส่วน Range แบบเดิม ๆ ก็จะค่อย ๆ ถูกแทนที่ด้วย type ใหม่ที่แทบเหมือนเดิมเพื่อแก้ปัญหา API เล็กน้อย std::error::Error::description ถูก deprecated เพราะ type ของ error ส่วนใหญ่ไม่อยากเก็บสตริงไว้ และมีตัวแทนตรง ๆ อยู่แล้วคือการ implement Display
    เมื่อคิดว่า std มีเสถียรภาพมานาน 11 ปี เรื่องนี้ก็น่าทึ่งมาก และส่วนที่เหลือของ std ก็ยังคงอยู่และใช้งานได้ โดย 98% ยังถือว่าเป็น Rust แบบที่ใช้กันตามปกติ ตรงกันข้ามกับ ไลบรารีมาตรฐานของ C++ ที่ดูเหมือนจะเพิ่มฟีเจอร์ง่ายเกินไป แต่กลับอยู่ในจุดอันตรายที่อนุรักษ์นิยมจนน่าตกใจกับการ deprecate ไม่ว่าในกรณีไหนก็ตาม

    • ไม่รู้เรื่อง Leakpocalypse นี้มาก่อนเลยจริง ๆ: faultlore (2015)
    • ยังนึกถึงปัญหาที่ trait Iterator ยืมของที่อยู่ข้างในตัวเองด้วย เป็นปัญหาเรื้อรังที่โผล่มาในบทสนทนาเรื่อง Rust ตลอดในรูปแบบ “ทำไมอันนี้ใช้ไม่ได้และต้องอ้อมแบบนี้”
      ในทำนองเดียวกัน การที่ f32 และ f64 ไม่ implement Cmp แล้วใช้เมธอด f32::total_cmp แทน ก็เป็นจุดน่าหงุดหงิดที่วิศวกรใหม่เจอบ่อย จนต้องถอนหายใจแล้วอธิบายที่มา
      กลไกการฟอร์แมต panic ก็ไม่ค่อยดีนัก และมีบล็อกโพสต์จำนวนมากพูดถึงว่าตัว panic handler เริ่มต้นใช้การฟอร์แมต และปิดได้ยาก ทำให้ขนาดไฟล์ executable โตขึ้นพอสมควร
  • ส่วนตัวผมคิดว่า การออกแบบไลบรารีมาตรฐานที่ล้าสมัย เป็นสิ่งที่บั่นทอนทั้งความนิยมและความใช้งานง่ายของ C++ อย่างมาก
    ปัญหาหลายอย่างที่คนโทษตัวภาษา จริง ๆ แล้วควรหันไปโทษฝั่งไลบรารีมาตรฐานมากกว่า
    ตัวอย่างเช่นคำพูดว่า “C++ คอมไพล์ช้า” นั้นไม่แม่นนัก ไม่ใช่ว่าการใช้ฟีเจอร์ของ C++ ทำให้ช้าโดยเนื้อแท้ แต่เป็นไลบรารีมาตรฐานต่างหากที่ทำให้ช้า จาก header ที่พองโต การพึ่งพาจำนวนมาก และการใช้เทมเพลตเกินความจำเป็นแม้กับ abstraction ง่าย ๆ
    ส่วนคำว่า “C++ ไม่ปลอดภัย” ก็จริงบางส่วน แต่การออกแบบไลบรารีมาตรฐานยิ่งทำให้แย่ลงไปอีก ไม่มีเหตุผลว่าทำไมจะเอาแพตเทิร์นที่ปลอดภัยกว่าจากการออกแบบ API ของ Rust มาใช้กับไลบรารีมาตรฐานใหม่ไม่ได้ แน่นอนว่าหนึ่งในข้อดีใหญ่ของ C++ คือ ความเข้ากันได้ย้อนหลัง ดังนั้นนี่จึงเป็นปัญหาที่ซับซ้อนมาก

    • บางกรณีก็จริง เช่นทำให้ vec[idx] โยน exception หรือ abort แทนการเข้าถึงนอกขอบเขต/undefined behavior ได้ แต่ก็มีหลายกรณีที่เพราะความต่างของภาษา ทำให้สร้าง API ที่ปลอดภัยใน C++ ได้ยากกว่ามาก
      Rust มี destructive move เป็นพื้นฐาน แต่ C++ ไม่มี ดังนั้น API ของ smart pointer จึงเลี่ยงไม่ได้ที่จะ unsafe หรืออย่างน้อยก็ชวนประหลาดและ crash ง่าย เช่นทำให้โปรแกรม abort เมื่อเข้าถึง smart pointer หลังถูก move ไปแล้ว
      Rust มี lifetime annotation แต่ C++ ไม่มี ดังนั้น Rust จึงออกแบบ API ของ iterator ให้ป้องกันเรื่องอย่าง iterator invalidation ได้ แต่ใน C++ แทบเป็นไปไม่ได้จริง ๆ Rust ยังมี pattern matching ทำให้ API อย่าง Option รองรับแนวทาง “เช็กแล้วใช้ทันที” ได้อย่าง ergonomic แม้ C++ จะให้ std::option แบบที่การเข้าถึงค่าว่างไม่ก่อ UB ได้ แต่ก็น่าจะใช้งานงุ่มง่ามกว่าทั้ง C++ ปัจจุบันและ Rust มาก และตัวดำเนินการ ? ของ Rust ก็ช่วยมากในจุดนี้ด้วย
      ผมรู้ว่า C++ พอจะเติมอะไรคล้าย pattern matching เข้าไปได้ด้วย overload set แบบ std::variant แต่ก็ยังใช้ยากกว่าและพลาดง่ายกว่ามากในความเห็นผม
    • ผมคิดว่า C ก็เหมือนกัน ปัญหาหลายอย่างของ C เกิดจาก stdlib ที่แย่มาก
      ถ้ามีแค่ไลบรารีสมัยใหม่สำหรับสตริงและอาร์เรย์ มี generic container บางตัว และมีการรองรับ allocator แบบเนทีฟ C ก็น่าจะ ergonomic และใช้ง่ายขึ้นมาก แน่นอนว่าข้อบกพร่องของภาษาบางอย่างไม่ได้หายไปเพียงแค่เปลี่ยนไลบรารี แต่ถึงอย่างนั้นก็ช่วยไปได้ไกลมาก
      ถ้าดู codebase C สมัยใหม่ จะเห็นการใช้ไลบรารีแบบกำหนดเองอย่างกว้างขวางสำหรับ allocator, string, vector, hash table และงานเกี่ยวกับไฟล์ซิสเต็ม ซึ่งถ้ามีประสบการณ์กับ C หรือการจัดการทรัพยากรด้วยมืออยู่แล้ว ก็ไม่ได้ยากนักที่จะทำตามแนวทางนั้น
    • ที่บริษัทเราใช้ implementation ของ slice<T, N> ที่สามารถแทน “pointer ที่ชี้ไปยัง N ไบต์พอดี” หรือ “pointer ที่ชี้ไปยังจำนวนไบต์ตามใจได้”
      มี head(n), tail(n), slice(start, end) และตัวดำเนินการ index โดยทั้งหมดทำ boundary check
      การทำงานกับ abstraction แบบนี้สนุกมาก แต่ถ้าจะได้ภาษาที่ทันสมัยและปลอดภัยในระดับหนึ่ง ก็แทบต้องพอร์ตไลบรารีมาตรฐานของ Rust กับ Zig มาไว้บน C++ อยู่ดี ถึงอย่างนั้น สุดท้ายมันก็คุ้มกับแรงที่ลงไป
    • ถ้าลดการใช้เทมเพลตกับ abstraction ง่าย ๆ ลง จะไม่เสีย ประสิทธิภาพ ไปหรือ?
  • ถ้าจะเขียนบทความแบบนี้ ก็อยากให้เขียนเองเถอะ ต่อให้รายการอาจทำเอง แต่การเอาไปป้อน LLM แล้วปล่อยผลลัพธ์ขึ้นเว็บให้คนอ่านมันเสียมารยาทเกินไป ถ้าต้องเห็นประโยคแนวว่า “วิศวกรที่ทำงานจริง” ถูกสอนให้หลีกเลี่ยง “ฟีเจอร์ X” ตั้งแต่ “วันแรก” อีกสักครั้ง ผมคงจะเป็นบ้า
    สิ่งที่น่าอายคือมีเรื่องให้พูดเยอะมาก แต่กลับไม่ได้พูดอะไรเลยจริง ๆ บทความนี้ต้องมีเหตุผลที่เขียนขึ้นมา งั้นก็ พูดเหตุผลนั้นออกมา สิ คงมีบางอย่างใน C++ ที่ทำให้คุณหงุดหงิด หรือมีบางฟีเจอร์ที่ทำให้สับสน สิ่งที่ทำให้ฟีเจอร์เหล่านี้แย่ ไม่ได้มีแค่ความล้มเหลวเชิงออกแบบแบบวัตถุวิสัย แต่ยังรวมถึงผลกระทบที่มันมีต่อพวกเราด้วย
    อย่างเช่นเคยใช้ std::iterator แล้วโดนถล่มใน Slack ไหม หรือเคยไม่ใช้ cast เพราะ reinterpret_cast ยาว 16 ตัวอักษรจนกลัวฟอร์แมตบรรทัดจะเสีย ถ้าเรื่องแบบนี้มาโผล่บน Lobsters คงดีกว่า ถ้าไม่มีเรื่องพวกนี้ก็ไม่ต้องฝืนแต่งขึ้นมา และอย่าให้ GPU สร้างประโยคเดิม 10 แบบด้วยการคูณเมทริกซ์เลย แค่ใส่คอมเมนต์เฉพาะส่วนที่มีอะไรจะพูด ที่เหลือก็ทำเป็นตารางกับ bullet point ก็พอ

    • บทความนี้ไม่ได้อ่านแล้วรู้สึกเหมือนเขียนด้วย LLM
  • ใช้ C++ มา 20 ปีและตอนนี้ก็ยังใช้อยู่ แต่เห็นด้วยกับบทความนี้มาก สิ่งที่ดีมากจริง ๆ เวลาใช้ Rust ทุกวันนี้ ไม่ใช่เรื่อง memory safety เท่านั้น แต่คือ standard library และระบบนิเวศแพ็กเกจที่ยอดเยี่ยม
    ตัวอย่างเด่นคือไลบรารี ranges แม้มาตรฐานจะออกมาแล้ว 6 ปี แต่ standard library หลัก ๆ ก็ยัง implement ได้ไม่ครบถ้วน และถึงจะ implement แล้วก็ยังมี combinator แค่ไม่กี่ตัว ฝั่ง Rust ที่เทียบกันได้คือเมธอดของ Iterator ซึ่งมี 76 ตัว และถ้า cargo add ครั้งเดียว ก็ได้เพิ่มอีก 130 ตัวผ่าน trait ของ itertools
    อีกอย่างที่คิดถึงมากจริง ๆ คือ pattern matching ซึ่งทำให้สร้าง union type อย่าง std::variant ให้ ergonomic ได้ ข้อเสนอยังอยู่ระหว่างการอภิปราย แต่ใน C++26 ก็ยังไม่เข้ามาอยู่ดี ซึ่งน่าเสียดาย ตรงกันข้าม contracts กับ executors กลับเข้ามา ทั้งที่พูดตามตรงยังไม่เคยเห็นคนรอบตัวเรียกร้องสิ่งพวกนี้เลย

    • ปัญหาอย่างหนึ่งของ C++ คือไม่มี เกณฑ์อย่างเป็นทางการและมีเอกสารกำกับ ว่าฟีเจอร์ไหนควรเป็นฟีเจอร์ของภาษา และฟีเจอร์ไหนควรเป็นฟีเจอร์ของ standard library
      โดยทั่วไป เกณฑ์ที่ผมใช้มองเป็นแบบนี้ ถ้าฟีเจอร์หนึ่งรองรับ use case ที่พึงประสงค์ และไม่สามารถแสดงออกผ่าน standard library ได้ ก็ควรอยู่ในภาษา ถ้าเป็นไปได้ ก็ควรแยกมันออกเป็นองค์ประกอบอิสระที่เล็กที่สุด ซึ่งยังสามารถนำไปใช้ทำอย่างอื่นได้ด้วย
      ฟีเจอร์ที่ถูกใช้ในแทบทุก codebase ควรเข้าไปอยู่ใน standard library ถ้า type ใดถูกใช้เป็นอินเทอร์เฟซระหว่างไลบรารีอยู่บ่อย ๆ ก็ควรเข้าไปอยู่ใน standard library เช่นกัน เราไม่ได้อยากให้ทุกไลบรารีต้องนิยาม tuple type หรือสตริงของตัวเอง C++ เคยเป็นแบบแรกแทบจะจริงอยู่แล้วก่อน C++11 และแบบหลังก็ยังเป็นอยู่จนถึงตอนนี้เพราะ std::string เป็น disaster เรื่องนี้ใช้กับ interface type ด้วย และทุกวันนี้ C++ จัดการเรื่องนี้ด้วย concepts เป็นส่วนใหญ่
      ที่เหลือควรอยู่ในไลบรารีแบบโมดูลาร์ที่นำกลับมาใช้ซ้ำได้ Rust ค่อนข้างเก่งในการมีชุด external library ที่ทั้งเสถียรและได้รับการรับรอง จึงมีแรงกดดันน้อยกว่ามากที่จะพูดว่า “เกมทุกเกมที่เขียนด้วย Rust ต้องใช้โครงสร้างข้อมูลนี้ งั้นเอาเข้า standard library เลย” เพราะคนเขียนเกมก็แค่ดึง crate ที่ต้องใช้มาเท่านั้น C++ ไม่เคยยอมรับแนวคิดเรื่อง “แพ็กเกจที่ดีสำหรับแนะนำกับปัญหาที่คนจำนวนมากแต่ไม่ถึงครึ่งหนึ่งพบเจอ” อย่างจริงจังเลย
  • สิ่งที่น่ากังวลคือ ในบรรดา ของที่กำลังเพิ่มเข้ามาตอนนี้ มีอะไรบ้างที่จะสุดท้ายต้องย้อนกลับในภายหลัง Contracts เพิ่งถูกใส่เข้ามาใน C++26 แต่ก็มีคนชี้ถึงข้อบกพร่องด้านการออกแบบที่ร้ายแรงแล้ว
    ไม่ได้อยากโจมตี “การออกแบบโดยคณะกรรมการ” แบบเหมารวม เพราะคิดว่าองค์กรลักษณะนี้มีเป้าหมายสำคัญที่ต้องทำ และมีจุดแข็งเฉพาะตัว เพียงแต่จุดแข็งนั้นไม่ได้อยู่ที่การออกแบบฟีเจอร์ใหม่เอี่ยมบนกระดาษเปล่า
    จุดที่ WG21 และ WG14 โดดเด่นจริง ๆ คือการนำฟีเจอร์ที่มีการสำรวจ design space มาแล้วในระดับหนึ่ง และถ้าเป็นไปได้ก็มี implementation ที่ใช้งานอยู่เดิมหลายแบบ มาทำให้เป็นฟีเจอร์มาตรฐานที่ทั้งผู้ใช้และผู้ implement ส่วนใหญ่ยอมรับได้ std::embed เป็นตัวอย่างของกรณีนี้
    ในทางกลับกัน ถ้ามาตรฐานออกมาก่อนที่ใครสักคนจะทำ implementation ได้ดีจริง ๆ เหมือน GC extension, std::memory_order_consume หรือ C++20 modules ที่อยู่ในบทความ ผลลัพธ์มักจะแย่มาก

    • ทั้ง C++ และ Haskell ต่างก็เป็นภาษาที่คณะกรรมการออกแบบเหมือนกัน แต่สองภาษานี้แทบจะอยู่คนละขั้วกันเลย ให้คิดถึงข้อนี้ทุกครั้งเวลาคุณเริ่มอยากเชื่อว่า “$X ถูกออกแบบโดยคณะกรรมการ” เป็นสิ่งที่บอกอะไรบางอย่างเกี่ยวกับ $X
  • ก่อนหน้านี้ผมเพิ่งตระหนักว่า C++ ไม่ได้ทำ versioning ให้ standard library แล้วก็ช็อกพอสมควร ไม่คิดว่าบทความนี้จะชี้ประเด็นนั้นตรง ๆ
    ที่พูดถึงว่า Go ก็มีความอนุรักษ์นิยมคล้ายกันในเรื่อง forward compatibility ก็น่าสนใจ แต่ Go ก็อนุรักษ์นิยมคล้ายกันแม้กับฟีเจอร์ใหม่ที่เพิ่มเข้ามา เลยดูเหมือนจะหลีกเลี่ยงปัญหาส่วนใหญ่ของ C++ ไปได้ การไม่มี ABI ที่เสถียรก็น่าจะช่วยด้วย
    ในบรรดาไลบรารียอดนิยมที่ผมรู้จัก มีแค่ libcamera ที่เปิดเผย C++ ABI อย่างชัดเจน ซึ่งค่อนข้างน่ารำคาญ จากประสบการณ์ของผม แม้แต่ไลบรารี C++ ก็มัก export symbol เป็น C ABI ซึ่งทำให้ interoperable กับภาษาอื่นได้ง่ายขึ้นด้วย อาจเป็นไปได้ว่าผมพลาดแนวโน้มไป
    แล้วก็ ABI compatibility ระหว่าง Clang กับ MSVC ก็มี quirks อยู่ไม่ใช่เหรอ? ผมจำได้ว่า Conan เคย discouraging หรือห้ามการผสมคอมไพเลอร์อย่างชัดเจน เลยสงสัยว่าทำไมคณะกรรมการ C++ ถึงพยายามรักษา ABI stability มากขนาดนั้น

    • นั่นก็ไม่ถูกทั้งหมด C++ ไม่ได้ทำ versioning ให้ standard library แบบ แยกจากตัวภาษา ก็จริง
      ที่นี่มีสองอย่างที่เกี่ยวข้องกันอย่างใกล้ชิด: ตัวสเปกของ standard library กับ implementation ของมัน สเปกนั้นเป็นของชุดภาษา+ไลบรารีแบบสมบูรณ์ และ implementation ก็มักพยายามรองรับอย่างน้อยหนึ่งเวอร์ชันของสเปกหรือมากกว่า
      มีไลบรารีจำนวนมากที่เปิดเผย C++ interface รวมถึงตัวใหญ่มากอย่าง Qt
      ปัญหาคือ abstract machine ของ C++ ไม่ได้กำหนดกระบวนการลิงก์ไว้ ดังนั้นจึงนิยามไม่ได้ว่า dynamic library ควรทำงานอย่างไร บนระบบ UNIX การ dynamic link ของ C++ เดินตามโมเดลของ C มันเหมือนแกล้งทำเป็นว่ามี dynamic link แล้วโยนปัญหาให้ loader จัดการ ผลก็คือมีเรื่องน่ากลัวอย่าง copy relocation เกิดขึ้น Windows มีแนวคิดที่เป็นหลักการกว่ามากว่า shared library คืออะไร แต่เพราะแบบนั้น idiom บางอย่างของไลบรารี C++ บน UNIX จึงใช้บน Windows ไม่ได้
      shared library มีปัญหาใหญ่กับฟีเจอร์อย่าง C++ template ถ้าจะ instantiate template ด้วย user type ได้ definition ทั้งหมดก็ต้องอยู่ใน header เพราะคอมไพเลอร์มองข้ามขอบเขต compilation unit ไม่ได้ ใน shared library โค้ดเดียวกันจึงถูก instantiate หลายที่ ถ้าโปรแกรมกับไลบรารี instantiate template เดียวกันด้วยพารามิเตอร์เดียวกัน ทั้งคู่ก็จะมีสำเนาของตัวเอง แล้ว linker กับ loader ต้องทำให้สุดท้ายในโปรแกรมที่ถูกโหลดใช้เพียงตัวเดียว
      ถ้าเทียบกับ Swift, Swift ระบุชัดว่า “shared library มีอยู่จริง และเปิดเผยโครงสร้างระดับภาษาที่ใช้แทนมัน” ถ้าคุณอยากเปิดเผย generic ข้ามขอบเขต shared library ก็ทำได้ แต่สำหรับ caller ภายนอกทั้งหมด มันจะถูกลดระดับเป็นเวอร์ชัน dynamic dispatch ใน C++ ก็ทำเองแบบ manual ได้ คุณสร้าง template เวอร์ชันทั่วไปที่ใช้ type-erased wrapper แล้วเขียน instantiation แบบ concrete อื่น ๆ อย่างชัดเจน แต่สิ่งนี้ทั้งยากและต้องทำด้วยมือ ใน Swift มันก็แค่ “นี่คือสิ่งที่เกิดขึ้นที่ขอบเขต shared library”
      การซ่อน type ก็คล้ายกัน C++ ใช้แพตเทิร์น pImpl เพื่อสร้าง public interface ที่เปิดเผยพฤติกรรมข้ามขอบเขตไลบรารีแต่ซ่อน implementation ไว้ Swift มี abstract machine ที่รู้ว่าขอบเขตไลบรารีอยู่ตรงไหน และบอกว่า “ขนาดของ type ที่ไม่ได้ระบุว่า ABI-stable ไม่ใช่ค่าคงที่ตอนคอมไพล์เมื่อมองข้ามขอบเขต shared library”
      นี่ยังเป็นอีกตัวอย่างหนึ่งของการที่มาตรฐานปฏิเสธความเป็นจริงด้วย codebase C++ ที่ไม่ trivial แทบทุกอันที่ผมเคยทำงานด้วยถูกคอมไพล์ด้วย -fno-rtti -fno-exceptions หรือออปชันเทียบเท่าของ CL.EXE แต่มาตรฐานไม่ยอมรับว่านี่เป็นความเป็นไปได้ ฟังก์ชันใน standard library ส่วนใหญ่ยังคาดหวังการรายงานข้อผิดพลาดผ่าน exception ดังนั้นถ้าคอมไพล์ด้วย -fno-exception มันก็จะเรียก abort ไปเลย ผลคือองค์ประกอบของ standard library ที่มีการจัดสรรหน่วยความจำแบบไดนามิกใช้บน embedded ไม่ได้ std::vector<T>::push_back อาจทำให้โปรแกรม crash ได้
      ประเด็นในบทความที่ว่า “คณะกรรมการไม่เพียงเอาฟีเจอร์แย่ ๆ ออกไม่ได้ แต่ยังคงเพิ่มฟีเจอร์ใหม่ที่วิศวกรภาคปฏิบัติไม่ได้ร้องขอ” เหมือนกับวิธีที่ contracts เกิดขึ้นทุกประการ Verus แสดงให้เห็นว่าระบบ contracts ที่ดีในภาษาซึ่งมุ่งเป้าไปยังสภาพแวดล้อมแบบ C++ สามารถทำอะไรได้บ้าง ส่วน P2900 contracts เป็นการรวมข้อกำหนดที่ขัดแย้งกัน ทำให้ทุกปัญหาที่ contracts อาจแก้ได้กลับแย่ลง
      ผมไม่คิดว่าข้อสรุปที่ว่า “วิศวกร C++” ได้ค่าจ้างสูงกว่า “วิศวกรที่เขียนโปรแกรมได้” มากนั้นเป็นจริง ในความเป็นจริงไม่มีใครเขียนโค้ดตามมาตรฐาน C++ แบบตรงตัว ทุกคนเขียนตาม subset-of-a-superset ภายในองค์กรที่ตัวเองชอบกันทั้งนั้น
    • ตรงนี้ go vet ก็มีคุณค่าเหมือนกัน เพราะมันให้ การอัปเกรดอัตโนมัติเพื่อปรับปรุง API
  • ตั้งแต่ปีที่แล้วผมแทบเลิกใช้ C++ ไปแล้ว ตอนแรกย้ายไป Kotlin แล้วต่อมาก็ Swift แม้ที่บริษัทยังต้องดูแลงานบำรุงรักษา C++ อยู่ แต่โค้ดใหม่ที่เขียนนั้น สะอาด กระชับ และปลอดภัยกว่า มาก มี tradeoff เรื่องขนาดโค้ดและอาจรวมถึงประสิทธิภาพอยู่บ้าง แต่ก็คุ้มค่า

  • ผมจำได้ว่าความหมายของ for loop ใน Go เคยเปลี่ยนแบบทำลาย backward compatibility เลยเคยคิดว่าประโยคนี้ผิด: https://go.dev/blog/loopvar-preview
    แต่พอไปดูจริง ๆ Go ก็ใช้แนวทางคล้าย Rust editions ตรงนี้ คือความหมายจะเปลี่ยนก็ต่อเมื่อประกาศเวอร์ชัน Go เป็น 1.22 ขึ้นไป บางที io/ioutil ก็คงถอดออกด้วยวิธีนี้ได้เหมือนกัน แต่ก็คงยังไม่คุ้มพอจะทำให้โค้ดพังข้ามเส้นแบ่งของ edition

  • ถ้า C++ ไม่ได้ลองทำไอเดียแย่ ๆ พวกนี้จริง ๆ แล้วพิสูจน์ว่ามันแย่ Rust ในรูปแบบปัจจุบันอาจไม่มีวันเกิดขึ้นเลยก็ได้ ขอบคุณมาก!

  • ผมสนใจ ตัวแทน standard library แบบ Rust สำหรับ C++ รู้จัก rpp ที่ตั้งเป้าไปทางนั้นอยู่แล้ว: https://github.com/TheNumbat/rpp
    มีตัวเลือกอื่นอีกไหม? ไม่ได้หมายถึง implementation อื่นของ C++ stdlib แบบ EASTL แต่หมายถึงไลบรารีที่เดินตาม Rust มากกว่า ผมรู้ว่าเรื่องบางอย่างอย่าง std::initializer_list ถูกฝังอยู่ในไวยากรณ์แล้ว แต่ที่เหลือเปลี่ยนได้ทั้งหมด