ผมเห็นฟีเจอร์นึงของ Rust ที่มีคนพูดถึงคือ ownership ซึ่งมันเป็นการบอกว่า ตัวแปรไหน ใครเป็นเจ้าของ ใน C++ ก็มีฟีเจอร์นี้เหมือนกัน โดยเราเริ่มพูดคุยกันจริงจังในช่วงการพัฒนา Modern C++ (ก็ก่อน Rust หลายปีอยู่) แต่ว่ามันมาในรูปแบบของไลบราลีครับ ซึ่งก็คงจะไม่แน่นเท่าฝั่ง Rust
ก่อนจะไปถึงฟีเจอร์ในไลบราลีที่ว่านั่น ผมขอเริ่มจาก reference ก่อน
Reference
สมมติว่า เรามี struct นึง หน้าตาแบบนี้
struct Point {
float x;
float y;
}
และเรามีโค๊ดแบบข้างล่างนี้
Point p{ .x = 100.0f, .y = 200.0f}; // Designated Initializer ฟีเจอร์ใหม่ของ C++20
float &x = p.x;
คนที่เป็นเจ้าของ float ตัวนี้ก็คือ p ดังนั้นถ้า p ถูกทำลายไป ค่านี้ก็จะหายไปด้วย
อันนี้คือนิยามของ ownership เลยครับ คือ คนที่เป็นเจ้าของ object ใดก็ตาม พอคนนี้ถูกย้ายไป หรือถูกทำลายไป object นั้นจะตามไปด้วย
ลองดูตัวอย่างถัดไป สมมติว่าผมมี
auto *p = new Point({.x = 200.0f, .y = 50.0f});
float &x = p->x;
delete p;
std::cout<<x<<std::endl; // error!
เป็นโค๊ดที่น่าเกลียดน่าดู (ฮา) คือ เนื่องจาก x เป็นค่าที่ refer ไปวัตถุที่ตัวพอยน์เตอร์ p ชี้ไป พอเราไปลบมัน x ก็เลย refer ไปไหนก็ไม่รู้ เพราะว่า x ไม่ได้เป็นเจ้าของตัววัตถุที่มัน refer ไปถึง
(เอาจริง ๆ เราก็ไม่ควรใช้ new/delete หรอกครับ แต่อันนี้ใช้แค่อธิบายโค๊ดเฉยๆ)
พูดง่าย ๆ reference มันเป็นแค่ป้ายครับ มันไม่ได้เป็นเจ้าของอะไร ก็เหมือนกับไอ้ NFT กับพวกงานศิลปะนี่แหละ NFT ไม่ได้แสดงความเป็นเจ้าของอะไร
ดังนั้นพอเราเห็น reference เนี่ย บอกได้เลยว่า มันไม่ได้เป็น owner ของวัตถุที่มัน refer ไป พอ reference หายไป ค่านั้นก็ยังอยู่ และถ้ามันดัน refer ไปหาวัตถุที่มีเจ้าของ และเจ้าของนั้นดันหายไปแล้ว reference นั้นก็จะพาลพังไปด้วย
ขอนอกเรื่องนิ๊ดเดียว ส่วนตัวคิดว่า reference เนี่ย ถ้าใช้เป็นแค่จุดพักข้อมูลระยะสั้น ๆ เนี่ยดีครับ แต่ถ้าเป็นตัวแปรที่ใช้ยาว ๆ มันสามารถสร้างปัญหาที่หาไม่เจอง่าย ๆ ได้ด้วยนะ (เพราะว่ามันไม่ได้เป็นเจ้าของค่าที่มัน refer ไปไง) อันนี้ระวังนิดนึง
Pointer
ต่อจาก reference ก็มาต่อที่ pointer ซึ่ง เมื่อเทียบกับ reference แล้ว อันนี้จะกำกวมมากกว่า คือ ถ้าเราเห็นตัวแปรเป็น reference เราสามารถบอกได้เลยว่า เฮ้ยเราไม่ได้ own ไอ้ค่าที่มันชี้ไปนะ แต่พอเป็น pointer เนี่ย เราบอกไม่ได้ว่าเราเป็นเจ้าของหรือเปล่า
เพราะมันอาจจะเป็นการรับค่ามาจาก reference operator ก็ได้ หรือมาจาก new
operator ก็ได้เหมือนกัน หรือแม้กระทั่ง new[]
operator หรือจะลามไปเป็น string ฯลฯ
ปวดหัวกันมากมาย
Smart Pointers
ในก๊วน C++ ก็เลยสร้าง type ใหม่ขึ้นมาสำหรับพวก pointer ที่เป็นเจ้าของวัตถุที่มันชี้ไป จะเรียกว่าใช้แทน new
เลยก็ได้ โดยมีอยู่ 3 type ข้างล่าง (จริง ๆ มี 4 แต่ deprecate ไปหนึ่ง)
-
unique_ptr
เป็น pointer ที่มีเจ้าของเพียงหนึ่งเดียวเท่านั้น ไม่สามารถสร้างก๊อปปี้ได้ -
shared_ptr
เป็น pointer ที่ทุก ๆ ก๊อปปี้ของ pointer นี้เป็นเจ้าของร่วมกัน -
weak_ptr
เป็น pointer ที่ไม่เป็นเจ้าของใคร สามารถขอความเป็นเจ้าของชั่วคราวจากshared_ptr
ได้
unique_ptr
คำว่า "เจ้าของ" ในที่นี้นั้น จะผูกอยู่กับ lifetime ของตัว pointer เอง ในกรณีของ unique_ptr
นั้น เมื่อตัว pointer ถูกทำลาย ตัววัตถุที่มันชี้ไปก็จะถูกทำลายตามไปด้วย อย่างเช่นตัวอย่าง
#include <memory>
{
std::unique_ptr<int> p = std::make_unique<int>(200);
} // สิ้น scop ตัว p ถูกทำลาย ค่าที่ p ชี้ไปก็จะถูกทำลายไปด้วย
ลักษรณะเฉพาะอันนึงที่ทำให้มัน unique คือ คุณไม่สามารถสร้างสำเนาของมันได้ วิธีเดียวที่จะสร้างสำเนาได้คือใช้ get()
เช่น
std::unique_ptr<int> p1 = std::make_unique<int>(2000);
std::unique_ptr<int> p2(p1.get());
ซึ่งคงไม่ต้องให้บอกว่า หายนะกำลังรอคุณอยู่ ....
free(): double free detected in tcache 2
timeout: the monitored command dumped core
shared_ptr
สำหรับกรณีของ shared_ptr
นั้น ตัว pointer จะถือ reference counter ร่วมกันอยู่ ทุกครั้งที่มีการสร้างสำเนาของ pointer เจ้า counter นี้ก็จะเพิ่มขึ้นด้วย และเมื่อสำเนาของ pointer นี้ถูกทำลาย counter ตัวนี้ก็จะลดลง
เมื่อ counter มีค่าเป็น 0 ตัวค่าที่ pointer ชี้ไปก็จะถูกทำลาย
#include <memory>
{
std::shared_ptr<int> p1 = std::make_shared<int>(100); // counter = 1
{
std::shared_ptr<int> p2 = p1; //counter = 2
} // สิ้น scope ใน p2 ถูกทำลาย counter = 1
} // สิ้น scope นอก p1 ถูกทำลาย counter = 0 และ ค่าที่ชี้ไปก็ถูกทำลายด้วย
weak_ptr
และสุดท้าย คือ weak_ptr
อันนี้จะงงนิดนึง เป็นตัวที่ใช้คู่กับ shared_ptr
เราไม่สามารถใช้มันเดี่ยว ๆ ได้ และไม่สามารถใช้มันตรง ๆ ได้ด้วย
คืออารมณ์ประมาณนี้ครับ
#include <memory>
std::shared_ptr<int> sp = std::make_shared<int>(200);
std::weak_ptr<int> wp(sp);
if(auto temp = wp.lock()) // lock ค่า wp เพื่อป้องกันไม่ให้ค่าที่ตัวมันชี้ไปถูกทำลายโดย sp
// จริง ๆ คือมันก็สร้าง copy ของ sp แล้วคืนค่ากลับมาให้ใช้นี่ล่ะ
{
std::cout<<*temp<<std::endl;
}
sp = std::make_shared<int>(2000); // เขียนค่าทับลงไป ทำให้ค่าเดิมที่ sp เคยชี้ไปหายไปด้วย
if(auto temp = wp.lock()) // wp ล๊อคไม่ได้ เพราะค่าที่มันเคยชี้ไปถูกทำลายไปแล้ว
{
std::cout<<*temp;
}
else
{
std::cout<<"failed to lock."<<std::endl; // ถึงจะล็อคไม่ได้ โปรแกรมก็ยังไม่พัง ก็ยังหายใจกันต่อ
}
200
failed to lock.
ผมคิดว่า weak_ptr
นี่ เวลาใช้ต้องคิดนิดนึง แอบใช้ยากอยู่ครับ แต่มันมี use-case แหละ เมื่อเทียบกับ shared_ptr
ซึ่งทำงานใกล้เคียงกับพวก reference ในภาษาอื่น ๆ แล้ว weak_ptr
นี่ต้องคิดนิดนึงเวลาใช้เลยล่ะครับ
วิธีใช้ล่ะ?
ส่วนตัวผมใช้ unique_ptr
สำหรับวัตถุหนึ่งเดียวที่มีในโปรแกรม พวกวัตถุที่แทนค่า device หรือพวก global instance ต่าง ๆ
ส่วน shared_ptr
จะเป็นพวก pointer ทั่ว ๆ ไป พวก instance ต่าง ๆ จะใช้ตัวนี้ครับ
ส่วน weak_ptr
นี่ ผมว่าเหมาะกับการเอาไปใช้ใน function ที่รับค่าเป็น shared_ptr
reference อย่างน้อยก็เช็คได้ว่าค่าที่มันชี้ไปยังใช้งานได้นะ แล้วก็เหมาะกับพวก instance ที่ไม่ได้แคร์ว่า ไอ้ pointer ที่มันถืออยู่จะใช้ได้หรือไม่ได้ ถ้าใช้ไม่ได้ก็ข้ามไป อะไรแบบนี้ครับ (อธิบายยากนิดนึง)
แล้วเคสอื่นๆ ล่ะ
เอากรณีที่ผมนึกออกนะครับ
- ใช้ pointer เป็น string << ใช้
std::string
แทน - ใช้ pointer เป็น dynamic array << ใช้
std::vector
แทน อันนี้หลายคนไม่รู้ จริง ๆstd::vector
ก็คือ wrapper ของ dynamic array นี่ล่ะ ใช้ง่ายกว่าด้วย
แล้วพอหมดพวกเคสพิเศษพวกนี้ ที่เหลือคือกรณีที่ pointer มันชี้ไปหาวัตถุที่มีเจ้าของอยู่แล้ว อันนี้คือเราปล่อยได้เลย ไม่ต้องไปยุ่งกับมันครับ
สรุป
ตัว ownership เป็นคอนเซปท์ที่ เอาจริง ๆ ณ.จุดนี้ก็ไม่ใหม่แล้ว แต่ว่าสำหรับคนที่เขียนโค๊ดสไตล์เก่าแบบ C++98 มานานก็อาจจะไม่คุ้นเคย
คอนเซปท์นี้ เมื่อใช้ร่วมกับ datatype ที่ออกแบบมาเพื่อทดแทนการใช้งาน pointer นอกเหนือจากความเป็น pointer (เช่น string, dynamic array) จะทำให้เราสามารถเขียนโค๊ดที่ปลอดภัยได้มากขึ้น ลดอัตราการเกิด dangling pointer และ memory leak ได้มากขึ้นครับ
Top comments (0)