Theppitak's blog

My personal blog.

29 ธันวาคม 2565

From C++ to Rust So Far

บันทึกเมื่อเรียน Rust ได้ครึ่งทาง

ผมเคยเรียน Rust ไปแล้วรอบหนึ่ง ตอนนั้นเรียนจากเว็บไหนก็จำไม่ได้ เขียนบนเว็บ คอมไพล์บนเว็บ ไม่เหลือร่องรอยในเครื่องให้ทบทวน แถมเนื้อหาก็ไม่ได้ลงลึกอะไร เรียนเหมือนไม่ได้เรียน ความมึนไม่ปรากฏ และไม่นานก็ลืม รอบนี้จึงขอเรียนแบบจริงจังเพื่อหวังให้นำไปใช้ได้จริง หลังจากที่ได้เห็นการใช้งานที่หลากหลายของภาษาในที่ต่างๆ จนดูเหมือนพยายามจะแทนที่ C/C++ ให้ได้

ครั้งนี้ผมเลือกที่จะเรียนแบบมี quiz จากแหล่งซึ่งมีเนื้อหาเดียวกับหนังสือ The Rust Programming Language (the book) แต่แทรก quiz เป็นระยะ

ซึ่งผมขอวิจารณ์ว่าเนื้อหายังอธิบายประเด็นสำคัญไม่ละเอียดเท่าที่ควร บางเรื่อง เช่น syntax ของ reference อ่านแล้วยังไม่เกิดความมั่นใจว่าเมื่อไรควรใช้แบบไหน ในขณะที่ quiz ซึ่งควรจะช่วยตรวจสอบความเข้าใจได้กลับมีหลายข้อที่ถามเกินเนื้อหาปัจจุบัน ทำให้วัดอะไรไม่ได้ และยังทำให้เกิดความไม่มั่นใจในสิ่งที่เรียน แต่หลายข้อที่ถามไม่เกินเนื้อหาก็ช่วยทบทวนได้ดีเหมือนกัน และบางข้อยังบ่งชี้ได้ว่าเนื้อหาที่อธิบายไปนั้นยังขาดรายละเอียดบางอย่าง จึงขอแนะนำผู้ที่จะเรียนจากแหล่งนี้ว่าพยายามทำ quiz เท่าที่ทำได้ แต่ถ้าทำไม่ได้เพราะข้อมูลยังไม่เพียงพอก็ไม่ต้องเสียกำลังใจ ข้ามๆ ไปบ้างก็ได้ แล้วค่อยกลับมาดูทีหลังเมื่อได้เรียนเพิ่มเติม

หลังจากเรียนมาได้ประมาณครึ่งหนึ่งของจำนวนบททั้งหมด ก็ขอเขียนบันทึกระหว่างทางสักหน่อยเกี่ยวกับประเด็นของ Rust ที่คิดว่าน่าสนใจ

Ownership

ด้วยความรู้ครึ่งทางนี้ ผมได้เห็นความพยายามของ Rust ที่จะจัดการ memory แบบไม่ใช้ garbage collection โดยขจัดปัญหา memory leak หรือ memory corruption ต่างๆ ที่พบใน C/C++ โดยเฉพาะใน C โดยเครื่องมือหลักที่ใช้คือ ownership ที่ฝังไว้ในตัวภาษาเลย

เวลาเขียน C เราจะสร้าง contract ต่างๆ ของ API ว่า object ต่างๆ ที่สร้างขึ้น เป็นหน้าที่ของใครในการทำลาย โดยเขียนไว้ใน API documentation และเป็นเรื่องของนักพัฒนาที่ ควร พยายามทำตาม บางครั้งมีการถ่ายโอนความรับผิดชอบในการทำลายไปให้ object อื่นก็ควรทำให้ชัดเจน โครงการไหนที่มีการจัดการเรื่องนี้ดีก็จะปลอดปัญหา แต่ถ้ามีจุดผิดพลาด ก็กลายเป็นการเขมือบทรัพยากรระบบ หรือทำให้โปรแกรมตาย หรือกระทั่งโดนจารกรรมข้อมูลได้ โดยมีการพัฒนาเครื่องมือต่างๆ มาช่วยตรวจสอบ เช่น valgrind

ถ้าเป็น C++ ก็มี constructor/destructor รวมถึง smart pointer ต่างๆ มาช่วยจัดการให้ ก็สะดวกกว่า C ขึ้นมาหน่อย แต่ก็ยังอาศัย วินัย ของโปรแกรมเมอร์บ้างอยู่ดี การแยก composition/aggregation ออกจาก association ใน class diagram ก็เพื่อสื่อสารเรื่องนี้

แต่ Rust กำหนดเรื่องนี้ไว้ในตัวภาษาเลย เรียกว่า ownership และใช้ move semantics เป็นหลักใน assignment และการ pass argument ทำให้มีการถ่ายโอน ownership เพื่อลดการ clone โดยไม่จำเป็น โดยที่ยังมี reference คล้ายของ C++ ให้ใช้ในกรณีที่แค่ ยืมใช้ (borrow) โดยไม่ take ownership โดยทั้งหมดนี้มีการตรวจสอบให้ตั้งแต่ตอนคอมไพล์!

ตอนที่เรียนเรื่องนี้ก็รู้สึกว่าเป็นความกล้าหาญมากที่ Rust พยายามจัดการ memory ด้วยคอมไพเลอร์ และถ้าทำได้จริงโดยไม่เพิ่มความยุ่งยากเกินไปก็เป็นเรื่องดี แต่ในใจก็สงสัยว่าจะทำได้แค่ไหน มีอะไรต้องแลกแน่ๆ ซึ่งก็เป็นไปตามนั้น เยอะเสียด้วย มันเหมือนการเลือก program design แบบหนึ่งที่อาจมีในโครงการ C/C++ บางโครงการ แล้วบังคับให้ใช้แบบนั้นไปเลย โดยบัญญัติเป็น syntax และ semantics ของภาษา โปรแกรมเมอร์ภาษา Rust ซึ่งเรียกว่า Rustacean (มันคือ Crustacean หรือสัตว์พวกกุ้งกั้งปูที่ไม่เขียน C? :P) ต้องใช้โหมดความคิดอีกโหมดหนึ่งในการเขียนโปรแกรมไปเลย

และขอบอกว่าสมัยที่เรียน C นั้น ผมไม่เคยมึนกับ pointer ของ C เท่ากับที่มึน ownership ของ Rust เลย! ตอนที่เขียน C นั้น ownership ต่างๆ เกิดจากวินัยที่เราสร้างขึ้นเองจากความเข้าใจ แต่พอมันกลายเป็นข้อบังคับของภาษา มันจะเหมือนการเดินที่ต้องคอยระวังไม่ให้เหยียบมดหรือกระทั่งทำให้มดตายทางอ้อม ขนาดนั้นเลย

Safe Borrows

บ่อเกิดของความมึนคือ Rust มีการสับรางการยืมไม่ให้เกิด race condition โดยอนุญาตให้มีการยืมแบบอ่านอย่างเดียว (immutable borrow) ได้พร้อมกันหลายทาง แต่ทันทีที่มีการยืมแบบเขียนได้ (mutable borrow) เกิดขึ้น การยืมอื่นทุกทางจะหมดอายุทันที ไม่ว่าจะยืมแบบเขียนได้หรือไม่ได้ และถ้ามีการใช้การยืมที่หมดอายุแล้ว ก็จะเกิด error ทันที โดยเป็น error ที่ตรวจพบตั้งแต่ตอนคอมไพล์!

การตรวจสอบนี้ ช่วยป้องกันบั๊กอย่างเช่นมีการลบ element ในลูปที่ iterate ใน vector เพราะการลบ element ต้องยืม vector แบบ mutable ทำให้ iterator ที่กำลังยืม vector อยู่เหมือนกันไม่สามารถ iterate ต่อไปได้ หากต้องการลบ element ในลูปจริงๆ ก็คงต้องเลี่ยงไปใช้ indexing แทน ซึ่งปลอดภัยกว่า เป็นต้น

ฟังดูเหมือนไม่มีอะไร แต่เอาเข้าจริงเวลาอ่านโค้ดมันซับซ้อนว่ามีการยืมซ่อนอยู่ที่ไหน ใครหมดอายุตอนไหน เวลาทำ quiz จะมึนมาก แต่เวลาคอมไพล์จริง error message ช่วยได้เยอะมาก

Lifetime

ใน C/C++ มีบั๊กประเภท dangling pointer หรือ use after free ที่เกิดจากการ dereference pointer ชี้ไปยัง object ที่ถูกทำลายไปแล้ว Rust ป้องกันปัญหานี้ด้วยการกำหนด lifetime ของ object ทำให้การใช้ reference ที่นอกจากไม่อนุญาตให้เป็น null แล้ว ยังไม่อนุญาตให้ reference ไปยัง object เกินอายุขัยของ object ด้วย ถ้าตรวจพบก็จะมี compiler error เช่นกัน!

จากสามเรื่องข้างต้น น่าทึ่งที่ Rust พยายามขนาดนี้เพื่อป้องกันปัญหา memory ตั้งแต่ตอนคอมไพล์ สิ่งที่แลกมาคือคุณต้องใช้ความระมัดระวังอย่างหนักในการเขียนโค้ด ซึ่งบางครั้งกฎเหล่านี้ยังครอบคลุมไปถึงกรณีที่พิสูจน์ได้ว่าปลอดภัยด้วย แต่ถ้ามันแหกกฎของ Rust ก็ถูกตีตกได้

ยังมีเรื่องอื่นที่ขัดกับความรู้สึกของคนที่มาจากภาษา C/C++

The Default Immutability

ในขณะที่ C/C++ และภาษาอื่นจำนวนมากใช้ ตัวแปร เพื่อการคำนวณ โดยปกติตัวแปรจึงสามารถเปลี่ยนค่าได้ ยกเว้นเมื่อต้องการห้ามเปลี่ยนจึงใส่ qualifier const กำกับ แล้วคอมไพเลอร์จะช่วยโวยวายให้ถ้ามีความพยายามจะเปลี่ยนค่าที่กำหนดเป็น const ไว้ แต่ Rust จะให้ตัวแปรเป็น const หรือ immutable โดยปริยาย เมื่อต้องการเปลี่ยนค่าจึงใส่ keyword mut (mutable) กำกับ นัยว่าเป็นการเอื้อต่อ functional programming และ concurrency กระมัง? แต่ในแง่หนึ่งก็เป็นการเน้นให้เห็นชัดเจนว่าค่าไหนมีโอกาสถูกเปลี่ยน เช่นตอนที่ให้ฟังก์ชัน ยืม object ไป อาจมีการเปลี่ยนค่าใน object นั้นกลับมา ซึ่งโค้ดที่เขียนด้วย C/C++ จำนวนมากที่ไม่ค่อยเคร่งครัดการใช้ const ก็จะเห็นเรื่องนี้ได้ไม่ชัด แต่กับ Rust ถ้าไม่เคร่งครัดคุณก็หมดสิทธิ์เปลี่ยนค่า ซึ่งก็อาจจะดี แต่คุณจะเผลอสับสนกับโหมดความคิดอยู่บ่อยๆ

The Default Move Semantics

ใน C++11 มีสิ่งที่เรียกว่า move semantics ซึ่งใช้ย้ายข้อมูลจาก object หนึ่งไปยังอีก object หนึ่งที่เป็น class เดียวกันพร้อมกับล้างค่าใน object ต้นทาง ทำให้เกิดการย้าย ownership โดยไม่มีสำเนาเกิดขึ้น ซึ่งนอกจากจะช่วยจัดการ ownership แล้ว ยังช่วยลดการ clone และ destruct โดยไม่จำเป็นอีกด้วย เพราะ move constructor สามารถย้ายเฉพาะ pointer ที่ชี้ data ได้เลย ไม่ต้องสำเนาตัว data ใน object ใหม่ และทำลาย data ใน object เก่า โปรแกรม C ที่ออกแบบดีๆ ก็สามารถ move data ในลักษณะนี้ได้เช่นกัน แต่เรื่องนี้ก็ยังไม่ใช่ default ของทั้ง C และ C++ ต้องมีการทำอะไรบางอย่างเพิ่มเติมเพื่อจะใช้ เช่น ใน C++ ก็ใช้ std::move()

แต่ Rust ใช้ move semantics โดยปริยาย ทั้งใน assignment และ function call ยกเว้นว่าต้องการทำสำเนาจึง clone เอา หรือแค่ให้ยืมผ่าน reference เอา ซึ่งก็อาจทำให้โค้ดโดยรวมมีประสิทธิภาพขึ้น แต่คุณก็ต้องระวังผลของการเปลี่ยนแปลง ownership ที่เกิดขึ้นด้วย

นอกจากนี้ ก็มีหลายสิ่งที่เป็นประโยชน์ใน Rust

Enum as Tagged Union

เวลาใช้ union ใน C/C++ เรามักใช้ร่วมกับ field ที่ระบุว่าเราใช้ข้อมูลชนิดไหนใน union นั้น ซึ่งเคยเห็นบางตำราเรียกว่า tagged union

Rust เอาสิ่งนี้มารวมเข้ากับ enum โดยกำหนดให้ enum สามารถมีข้อมูลประกอบได้ ซึ่งหาก implement ด้วย C/C++ ก็คือการใส่ union ประกอบกับ enum เป็น tagged union นั่นเอง ทำให้สร้าง tagged union ใน Rust ได้สะดวกสบาย ไม่ต้องเขียนโค้ดในการอ่าน/เขียนเอง

Option, Result

Rust ลงทุนสร้าง enum แบบมีข้อมูลประกอบ ก็นำมาใช้ประโยชน์กับค่า return ของฟังก์ชันที่สามารถ return ทั้งสถานะ success/failure และผลลัพธ์ที่ได้ในคราวเดียว

Option คือ enum ที่มีสองค่า คือ None กับ Some โดยค่า Some สามารถมีข้อมูลประกอบได้ ใช้กับฟังก์ชันที่ทำอะไรสักอย่างให้ได้ผลลัพธ์โดยอาจล้มเหลวได้ เช่น การค้นหาข้อมูล ในภาษา C/C++ เราอาจให้ฟังก์ชัน return สถานะ success/failure และถ้า success ก็เขียนข้อมูลลงใน parameter ที่ call by reference มา เช่น

bool Table::FindData(const char* key, Record& result) const;

หรืออีกแบบคือ return pointer ไปยัง object ที่สร้างขึ้นใหม่ โดยถ้าล้มเหลวก็ return NULL

Record* Table::FindData(const char* key) const;

แต่ Rust ไม่มี null pointer/reference แต่จะ return เป็น Option มาเลย

impl Table {
    pub fn find_data(&self, key: &str) -> Option<Record> {
        ...
    }
}

โดยผู้เรียกจะตรวจสอบค่า enum ที่ return มาก่อนดึงค่าออกมาใช้ก็ได้ หรือจะใช้ method ของ Option ในการแกะห่อเอาค่ามาใช้ก็ได้ ซึ่งเป็นวิธีที่ถือว่าดูดี

Result ก็คล้ายกัน คือเป็น enum ที่มีสองค่า คือ Ok กับ Err โดยค่า Ok สามารถมีข้อมูลผลลัพธ์ประกอบได้ และค่า Err ก็มีข้อมูล error ประกอบได้ (ต่างกับ None ของ Option ที่ไม่มีข้อมูลประกอบใดๆ)

Trait-Bound Generic

trait คือสิ่งที่คล้ายกับ interface ในภาษา OOP ต่างๆ แต่ไม่เหมือนกันเสียทีเดียว

generic ก็เหมือนกับ template ของ C++ ซึ่งหากใครเคยใช้ STL ของ C++ จะรู้ว่า type ที่จะมาเป็น parameter ของ template หนึ่งๆ มักมีข้อแม้บางอย่าง แล้วแต่ template ที่ใช้ เช่น ต้องเปรียบเทียบค่ากันได้ หรือต้องรองรับการ move ฯลฯ แต่ไม่เคยมีการประกาศชัดเจน จะรู้ได้ก็ตอนที่คอมไพเลอร์ฟ้องว่าขาดคุณสมบัติ แล้วค่อยไปหาทางอุดทีละเรื่องเอา (ผมว่านี่แหละคือสิ่งที่คล้ายกับ duck typing ใน C++)

แต่ generic ของ Rust สามารถประกาศคุณสมบัติของ type ที่เป็น parameter ได้ว่าต้องรองรับ trait ใดบ้าง ทำให้รู้แต่ต้นว่าต้องเตรียมคุณสมบัติอะไรไว้

Rust ยังมีรายละเอียดให้เรียนรู้อีกเยอะ นี่ผมเพิ่งมาได้ครึ่งทาง ก็ยังต้องเรียนรู้กันต่อไป

ป้ายกำกับ: , ,

hacker emblem