- Datatype99 เป็นไลบรารีแบบใช้แมโครที่มอบ algebraic data type, การทำ pattern matching แบบ exhaustive และการทำ introspection ตอนคอมไพล์สำหรับ C99 แบบล้วน
- มีเป้าหมายเพื่อจับ variant ที่ระบุชนิดผิด, pattern matching ที่ไม่ครบถ้วน, และการเข้าถึงฟิลด์ที่ผิดพลาดได้ตั้งแต่ ตอนคอมไพล์ โดยต้องการเพียงคอมไพเลอร์ C99 ที่เป็นไปตามมาตรฐาน
- ภายใน
datatype จะถูกขยายเป็น tagged union และ value constructor ส่วน match จะถูกขยายเป็นคำสั่ง switch และ layout ของข้อมูลที่สร้างขึ้นจะเป็นไปตาม semantics ของการสร้างโค้ดแบบกำหนดรูปแบบ
- การติดตั้งทำได้โดยเพิ่ม
datatype99.h และ dependency คือ Metalang99 เข้าไปใน include path และบน GCC/Clang แนะนำให้ใช้ตัวเลือกคอมไพล์เพื่อลดเอาต์พุตข้อผิดพลาดจากการขยายแมโคร
- สามารถรวมเข้ากับโค้ดเบส C ได้ด้วย
#include <datatype99.h> และเป็นที่ทราบว่าสามารถทำงานได้บน GCC, Clang, MSVC, TCC รวมถึงรองรับ C++11 ขึ้นไป
สิ่งที่ Datatype99 มอบให้
- Datatype99 มอบ algebraic data type ที่ปลอดภัยและเข้าใจง่ายสำหรับ C99
- ขอบเขตความสามารถครอบคลุมถึงการทำ pattern matching แบบ exhaustive และ การทำ introspection ตอนคอมไพล์
- ทำงานได้โดยไม่ต้องมีเครื่องมือสร้างโค้ดภายนอก และอิมพลีเมนต์ด้วย C99 ล้วน
- คุณสมบัติหลัก
- ความปลอดภัยของชนิดข้อมูล: จับ variant ที่ระบุชนิดผิด, pattern matching ที่ไม่ exhaustive, และการเข้าถึงฟิลด์ที่ผิดพลาดได้ตั้งแต่ตอนคอมไพล์
- ความพกพา: ต้องใช้เพียงคอมไพเลอร์ C99 ที่เป็นไปตามมาตรฐาน และไม่ต้องใช้ standard library, ความสามารถเฉพาะของคอมไพเลอร์/แพลตฟอร์ม หรือ VLA
- ความคาดเดาได้: มีการกำหนด semantics ของการสร้างโค้ด ไว้ จึงรับประกันได้ว่า layout ของข้อมูลที่สร้างจะเหมือนเดิมเสมอ
- ข้อผิดพลาดที่เข้าใจได้: ไลบรารีเองสามารถตรวจจับ syntax error บางส่วนในโค้ดที่ผิดได้
- กรณีใช้งานจริง: ถูกใช้ใน OpenIPC เพื่อพัฒนาซอฟต์แวร์สตรีมมิงแบบเรียลไทม์สำหรับกล้อง IP รวมถึง อิมพลีเมนต์ RTSP 1.0 และโค้ดปิดอีกราว 50,000 บรรทัด
การติดตั้งและการตั้งค่าการบิลด์
- Datatype99 ประกอบด้วยไฟล์เฮดเดอร์
datatype99.h เพียงไฟล์เดียวและ dependency Metalang99
- หากต้องการใช้ในโปรเจกต์ ต้องเพิ่ม
datatype99 และ metalang99/include เข้าไปใน include directory
- บน GCC แนะนำให้กำหนด
-ftrack-macro-expansion=0 และบน Clang ให้กำหนด -fmacro-backtrace-limit=1 เพื่อลดเอาต์พุตข้อผิดพลาดจากการขยายแมโครที่ไม่จำเป็น
- หากใช้ CMake แนะนำให้ใช้
FetchContent
datatype99/CMakeLists.txt เริ่มต้นจะดาวน์โหลด Metalang99 v1.13.5 จาก GitHub Releases
- พฤติกรรมนี้สามารถ override ได้โดยเรียก
FetchContent_Declare ไว้ก่อน
- เฮดเดอร์ที่พึ่งพา Datatype99 สามารถทำเป็น precompiled header ได้ เพื่อลดเวลาคอมไพล์โดยไม่ต้องคอมไพล์ใหม่ทุกครั้งที่ include
วิธีใช้งาน: ใช้ tagged union ให้ปลอดภัยขึ้น
- โดยพื้นฐานแล้ว Datatype99 คือ syntax sugar สำหรับ tagged union ที่มอบรูปแบบการใช้งานที่ปลอดภัยและกระชับกว่า
- ใน C ปกติ หากต้องการแทน binary tree ต้องเขียน
tag enum และ union ด้วยตัวเอง
- ใน Datatype99 สามารถประกาศโครงสร้างเดียวกันได้ดังนี้
datatype(
BinaryTree,
(Leaf, int),
(Node, BinaryTree *, int, BinaryTree *)
);
- ในวิธีแบบ
switch ปกติ แม้จะเผลอเข้าถึง tree->data.node หลัง case Leaf: คอมไพเลอร์ก็อาจไม่เตือน
- แต่เมื่อใช้
match และ of การ bind ตาม variant จะมองเห็นได้เฉพาะในสาขานั้น และหากเข้าถึงอย่างไม่เหมาะสมจะคอมไพล์ไม่ผ่าน
int sum(const BinaryTree *tree) {
match(*tree) {
of(Leaf, x) return *x;
of(Node, lhs, x, rhs) return sum(*lhs) + * x + sum(*rhs);
}
return -1;
}
- ตัวแปรที่
of สร้างให้ เช่น x, lhs, rhs จะเป็นตัวแปรแบบพอยน์เตอร์ จึงสามารถแก้ไขค่าได้
- การสร้าง variant ใช้ value constructor ที่ถูกสร้างขึ้นภายใน
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);
ไวยากรณ์และ semantics ของการสร้างโค้ด
- Datatype99 มีแมโครไวยากรณ์อย่าง
datatype, record, match, of, otherwise, MATCHES, ifLet เป็นต้น
- มีทั้งชื่อแมโครแบบย่อและเวอร์ชันที่ลงท้ายไว้พร้อมกัน
- ตัวอย่าง:
match99, of99, derive99
- หากต้องการหลีกเลี่ยงการชนกันของชื่อ สามารถกำหนด
DATATYPE99_NO_ALIASES ก่อน include datatype99.h
- สำหรับเฮดเดอร์ของไลบรารี แนะนำให้ใช้แมโครแบบลงท้าย
datatype จะสร้างองค์ประกอบต่อไปนี้
typedef แบบ forward declaration
- โครงสร้างสำหรับแต่ละ variant ที่ไม่ว่าง
typedef สำหรับชนิดของฟิลด์ใน variant
typedef สำหรับ sum type
- tagged union ที่มี
tag enum และ union
- value constructor แบบ
inline static สำหรับแต่ละ variant
- การเรียก deriver ที่ระบุไว้ใน
derive(...)
- แม้ทุก variant จะว่างเปล่า ตามมาตรฐาน C แล้ว union ก็ยังต้องมีสมาชิกอย่างน้อยหนึ่งตัว จึงมี
char dummy; ถูกใส่ไว้
record คือ struct ที่มีการกำหนด derivation process และแม้ไม่มีฟิลด์ก็จะสร้าง char dummy; เช่นกัน
match จะเปรียบเทียบอินสแตนซ์ของ sum type กับแต่ละ variant ตามลำดับ แล้วรันคำสั่งของสาขาที่ตรง ก่อนจะไปยังคำสั่งถัดไป
- โครงสร้าง
match และ ifLet แบบสมบูรณ์จะถูกขยายเป็น C statement เดียวในแต่ละกรณี
MATCHES ใช้ตรวจสอบแบบจริง/เท็จว่าอินสแตนซ์ของ sum type เป็น variant ที่ระบุหรือไม่
matches ถูกเลิกใช้แล้ว และแนะนำให้ใช้ MATCHES
derive และ helper attribute
derive(...) ใช้สำหรับสร้างโค้ดแบบ global ให้กับ sum type หรือ record
- deriver ของ sum type จะถูกเรียกในรูปแมโครที่เข้ากันได้กับ Metalang99 โดยรายการ variant จะถูกส่งเป็น tuple list
- deriver ของ record ก็จะถูกเรียกเป็นแมโครที่เข้ากันได้กับ Metalang99 เช่นกัน โดยรายการฟิลด์จะถูกส่งเป็น tuple list ในรูป
(<type>, <field-name>)
- derive helper attribute คือ อาร์กิวเมนต์แบบมีชื่อ ที่ส่งต่อให้ deriver
- helper attribute ใช้รูปแบบ object-like macro
#define <variant-name>_<namespace>_<attribute-name> attr(/* attribute value */)
- แมโครสำหรับจัดการ attribute ที่มีให้
DATATYPE99_attrIsPresent / DATATYPE99_ATTR_IS_PRESENT: ตรวจสอบว่ามี attribute หรือไม่
DATATYPE99_attrValue / DATATYPE99_ATTR_VALUE: ดึงค่าของ attribute ที่มีอยู่
DATATYPE99_assertAttrIsPresent: หากไม่มี attribute ที่จำเป็นจะเกิด fatal error
รูปแบบการใช้งานที่ควรระวัง
- ห้ามใช้
break/continue ระดับบนสุดภายใน statement ที่ส่งให้ of และ ifLet
continue ภายในลูป for/while ด้านในยังใช้ได้
- ควบคุมลำดับการทำงานระดับบนสุดควรแทนด้วย label และ
goto
- หากต้องการกำหนดอาร์เรย์เป็นพารามิเตอร์ของ variant ต้องใส่ไว้ใน
struct แยกต่างหาก
- binding ที่
of แนะนำเข้ามาจะ mutable เสมอ ดังนั้นหากค่าที่ส่งให้ match เป็น const ต้องระวังไม่ให้แก้ไขมัน
- หากต้องการให้คำจำกัดความ
datatype อ่านง่าย สามารถใช้ // clang-format off และ // clang-format on กับ Clang-Format ได้
- derive helper attribute ควรถูก
#undef เสมอหลังคำจำกัดความ datatype นั้น เพื่อหลีกเลี่ยงการทำให้ namespace ปนเปื้อน
- หากความหมายของพารามิเตอร์ variant ไม่ชัดเจนจากบริบทเพียงอย่างเดียว สามารถใช้ type alias หรือโครงสร้างแยกเพื่อให้ชื่อมีความอธิบายได้มากขึ้น
ข้อผิดพลาด, IDE, ความเข้ากันได้
- syntax error บางส่วนสามารถถูกตรวจจับได้โดยตัวไลบรารีเอง
- รูปแบบที่ไม่ใช่ tuple เช่น
Bar(int)
- comma ที่หายไป
- trailing comma ที่ห้ามใช้
- ข้อผิดพลาดอื่น ๆ จะปรากฏผ่านการวินิจฉัยปกติของคอมไพเลอร์
- ชื่อชนิดข้อมูลที่ไม่มีอยู่จริง
match ที่ไม่ exhaustive
- binder ใน
of ที่มากเกินไป
- อาร์กิวเมนต์ variant ที่ระบุชนิดผิด
- การ
return โดยไม่ dereference pointer binding
- ระบุว่าจากประสบการณ์แล้วเกือบ 95% ของข้อผิดพลาดแสดงผลออกมาอย่างมีความหมาย
- หากข้อผิดพลาดเข้าใจยาก สามารถใช้
-E เพื่อตรวจดูโค้ดที่ถูกสร้างขึ้นได้ และเนื่องจาก semantics ของการสร้างโค้ดถูกกำหนดไว้อย่างเป็นทางการ จึงมักไม่เกิดโค้ดที่เหนือความคาดหมาย
- VS Code สามารถเปิดใช้งานคำแนะนำสำหรับชนิดข้อมูลที่ถูกสร้างขึ้นได้อัตโนมัติ แต่ไม่รองรับการไฮไลต์ไวยากรณ์ของแมโคร
- เป็นที่ทราบว่า Datatype99 ทำงานได้บน GCC, Clang, MSVC, TCC
- รองรับ C++11 ขึ้นไปด้วย
เหตุผลที่มุ่งเป้าไปที่ C และข้อจำกัด
- ซอฟต์แวร์เดิมที่เขียนด้วย C ล้วนสามารถได้รับประโยชน์จาก Datatype99 ได้
- สามารถรวมเข้ากับโค้ดเบส C เดิมได้เพียง
#include <datatype99.h>
- ในบางสภาพแวดล้อมยังคงใช้ C ล้วนด้วยเหตุผลทางประวัติศาสตร์ เช่น อุปกรณ์ฝังตัว, Linux และระบบปฏิบัติการอื่น ๆ
- ABI ที่เสถียรของ C มีความสำคัญต่อโปรเจกต์ระบบปลั๊กอินอย่าง MetaCall
- C ถูกนำเสนอว่าเป็นภาษาที่เติบโตเต็มที่ มีสเปกที่สมบูรณ์ และมีไลบรารีจำนวนมาก
- หากสามารถใช้ภาษาที่ทันสมัยกว่าหรือระดับสูงกว่าได้ ก็แนะนำให้ใช้ภาษาเหล่านั้นแทน C แบบเก่า แต่สำหรับหลายคน ตัวเลือกนั้นไม่มีอยู่หรือมีต้นทุนสูง
- ความแตกต่างระหว่าง Datatype99 กับ Metalang99 อยู่ที่บทบาท
- Metalang99 คือภาษาฟังก์ชันนัลสำหรับเมตาโปรแกรมมิง
- Datatype99 คืออิมพลีเมนเตชันของ algebraic data type ที่เขียนด้วย Metalang99
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ที่ทำงานต้องใช้ Java และแม้จะเริ่มมองว่า Java ไม่ได้แย่เท่าที่เคยถูกตำหนิในอดีต แต่ก็มีหลายสิบครั้งที่คิดว่า “ถ้า Java มี discriminated union แบบ F# ก็คงดี”
มันเลียนแบบได้ด้วยเทคนิคหลายแบบ และหลายกรณีแค่ enum ก็เพียงพอ แต่ส่วนใหญ่ยังขาดความยืดหยุ่นและความกระชับของชนิดข้อมูลเชิงพีชคณิตที่แท้จริง ที่สำคัญที่สุดคือไม่มี pattern matching ดี ๆ แบบที่ได้จากภาษาเชิงฟังก์ชัน
ส่วนขยาย C ตัวนี้ดูเหมือนจะมี pattern matching อย่างที่ต้องการ จึงดูดีทีเดียว และคงต้องลองดูว่าใช้กับโปรเจกต์ Arduino ได้ไหม
คนที่เพิ่งรู้จักมักพูดไม่หยุดว่านี่เป็นสิ่งประดิษฐ์ที่ยอดเยี่ยมที่สุดนับตั้งแต่ขนมปังหั่นแผ่น :)
สำหรับ Java มี https://github.com/functionaljava/functionaljava ซึ่งหยุดซัพพอร์ตแล้วแต่เสถียร
มันทรงพลังมากจนขาดไม่ได้
ล่าสุดมีการรองรับ pattern matching ที่คอมไพเลอร์บังคับตรวจบน sealed class แล้ว จึงเหมือนมาได้ครึ่งทาง
ถ้าอยากดูว่าชนิดข้อมูลเชิงพีชคณิตทำงานภายในอย่างไร ผมคิดว่า libsum เป็นสื่อเริ่มต้นที่ดี
[1] https://github.com/naasking/libsum
ผมรู้จัก C มาเกือบ 20 ปี แต่ไม่เคยคิดเลยว่า ระบบมาโคร จะทรงพลังพอให้ทำมนตร์ดำแบบนี้ได้
เจ๋งจริง ๆ
ตามธรรมเนียมแล้วมักใช้เพื่อสร้างและเข้าถึง generic ที่ปลอดภัยด้านชนิด หรือเพื่อลดโค้ดซ้ำในการนิยาม hardware register และ interrupt
มันให้ความรู้สึกเหมือนต้องคำสาปนิด ๆ แต่แก่นจริง ๆ นั้นเรียบง่ายมาก และเป็นเครื่องมือที่เชื่อถือได้ในการลดความซับซ้อนทางความเข้าใจและโค้ดซ้ำในโปรเจกต์ที่ใช้ C
ในแง่การใช้มาโครแล้วไม่ได้ซับซ้อนขนาดนั้น
https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
มีบล็อกโพสต์ยาว ๆ ที่พูดเรื่องเดียวกันด้วย แต่ดูเหมือนผู้เขียนยังไม่เคยเห็นส่วนนั้นใน Wikipedia
https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
น่าชื่นชมที่ผู้พัฒนา datatype99 แสดงปัญหาของวิธีแก้ขัดแบบนี้ไว้ใน README ทันที
https://github.com/melt-umn/ableC-template-algebraic-data-ty...
ถึงอย่างนั้น นอกเหนือจากจุดนี้ก็เจ๋งมาก
นึกถึงตอนที่เคยสร้าง immediate-mode UI ด้วยมาโคร กรณีของผมไม่ได้เป็นปัญหา แต่ในบางบล็อกสามารถใช้ "break" ได้
เช่น ในบล็อก win_form สามารถออกด้วย "break" ได้ และ "goto" ก็ทำงานด้วย ในทางกลับกัน บล็อก win_command จะไม่ดัก "break" ดังนั้นถ้าใช้ break หรือ goto ภายใน win_command ก็จะออกจากบล็อกชั้นนอกที่ครอบ win_command อยู่ ซึ่งน่าจะเป็นบล็อก win_form ปกติมักใช้กับกรณีอย่างปุ่ม "Cancel"
ไม่ใช่แค่ดีกว่าเล็กน้อยในบางด้าน แต่เป็นการขจัดความขัดข้องเล็ก ๆ จำนวนมากที่สะสมตามกาลเวลาออกไปอย่างราบรื่นทั่วทั้งระบบนิเวศ เหมือนปัญหาที่สาย CPython มีชนิดคำศัพท์ของตัวเองมากเกินไป หรือในไลบรารีประมวลผลสมรรถนะสูงก็มีปัญหาแบบนั้น ผลสะสมจึงใหญ่
เช่น ส่วนของมาโครนี้ที่ก่อปัญหา หากเป็นมาโคร Rust ที่เขียนดี ๆ ก็คงไม่เป็นปัญหาตั้งแต่แรก มันเป็นผลพลอยได้จากการที่คนเก่ง ๆ พยายามเลี่ยงข้อจำกัดของ C
อย่างไรก็ตาม ตัวแมโครนี้เองเดิมก็เป็นการพอร์ตฟีเจอร์เนทีฟของ Rust มาอยู่แล้ว ดังนั้นใน Rust ก็คงไม่จำเป็นต้องเขียนมันตั้งแต่แรก และจึงถูกนำไปใช้ในซอฟต์แวร์ของชุมชนได้อย่างเป็นธรรมชาติ
แนวปฏิบัตินั้นหายไปแล้ว และตอนนี้ goto ที่ใช้ภายในฟังก์ชันค่อนข้างไม่เป็นพิษเป็นภัย และในทางปฏิบัติก็แทบเหมือนกับ break/continue
และสมมติว่ารู้อยู่แล้วว่ามี Rust แต่จะไม่ใช้ Rust ด้วยเหตุผลที่คนเขียนโปรแกรม C ต่างก็รู้กัน
ถึงอย่างนั้น อย่างน้อย Zig ก็น่าพิจารณา เมื่อสองวันก่อนผมเขียนโค้ดแบบนี้ด้วย Zig
ใช้
inline elseของcomptimeเพื่อสร้างทุกแขนงของ switch statement บน tagged union แล้วบวก offset ให้กับสมาชิกของ union ที่มีฟิลด์"l"หากเป็นข้อมูลชนิดที่รู้ได้ตอนคอมไพล์ ก็สามารถเปลี่ยนลักษณะของแต่ละแขนงได้หลากหลาย และข้อมูลนั้นก็มีค่อนข้างมาก เนื่องจากทุกเงื่อนไขถูกตัดสินในเวลาคอมไพล์ แต่ละแขนงของ switch จึงมีเฉพาะตรรกะที่จำเป็นต่อการจัดการ variant นั้น ๆ“แต่โปรแกรมของฉันเป็น C อยู่แล้วและต้องใช้แค่ในไฟล์เดียว” ถ้าเป็นเช่นนั้นก็ยังอยากให้ลอง Zig ดู อาจจะถูกใจก็ได้
https://gist.github.com/unclechu/eb37cc81e80afbbb5e74990b62e...
std::variant ก็ดูเหมือนจะทำงานได้ระดับหนึ่งคล้าย sum type
ปัญหาเดียวคือสร้าง switch statement ที่เรียบร้อยตามค่าบางอย่างของฟิลด์ไม่ได้ แต่ switch statement แบบซ้อนกันก็ไม่ได้รกขนาดนั้น
ปัญหาของแนวทางนี้คือ ภาระทางความคิด ที่ต้องดูแลรายละเอียดทั้งหมดไม่ให้ตกหล่นนั้นสูงมาก พอเหนื่อยเข้า ก็เริ่มยัดฟังก์ชันเข้าไปในคลาสเดิมแทนที่จะสร้างคลาสใหม่
สุดท้าย abstraction ก็ถูกมองว่าแพง และผลก็คือใช้ abstraction น้อยลง ทั้งที่อาจแก้ปัญหาได้อย่างงดงามกว่า
สรุปคือ ความสามารถในการพูดว่า “Darmok and Jalad at Tanagra” แทนที่จะต้องเล่าเรื่องยาวซ้ำทุกครั้งเมื่ออ้างถึงแนวคิดซับซ้อนนั้น เป็นสิ่งที่เปลี่ยนแปลงเกมได้
pattern matching คือข้อได้เปรียบด้านการใช้งานที่สำคัญ
ทำด้วยแมโครนั้นยาก แต่ไม่ใช่เป็นไปไม่ได้ โดยพื้นฐานต้องมีแมโครที่เริ่มบริบทการ match ต้องนำตัวแปรเข้ามาเพื่อติดตามทุกทางเลือกที่ตรวจแล้ว และต้องจัดให้เมื่อจบบริบทการ match มีการตรวจสอบว่าได้ตรวจครบทุกทางเลือกแล้วหรือไม่
วิธี match ให้ครบทุกกรณีด้วย std::visit รู้สึกแฮ็ก ๆ ถ้ามันกลายเป็น ฟีเจอร์ระดับ first-class ของภาษา จริง ๆ จะได้ประโยชน์มาก
มันอาจส่งผลต่อโค้ดในชีวิตประจำวันมากกว่าฟีเจอร์อื่น ๆ ที่ทำกันมาอย่าง coroutine ด้วยซ้ำ