DEV Community

Wutipong Wongsakuldej
Wutipong Wongsakuldej

Posted on

Ownership ในภาษา C++

ผมเห็นฟีเจอร์นึงของ Rust ที่มีคนพูดถึงคือ ownership ซึ่งมันเป็นการบอกว่า ตัวแปรไหน ใครเป็นเจ้าของ ใน C++ ก็มีฟีเจอร์นี้เหมือนกัน โดยเราเริ่มพูดคุยกันจริงจังในช่วงการพัฒนา Modern C++ (ก็ก่อน Rust หลายปีอยู่) แต่ว่ามันมาในรูปแบบของไลบราลีครับ ซึ่งก็คงจะไม่แน่นเท่าฝั่ง Rust

ก่อนจะไปถึงฟีเจอร์ในไลบราลีที่ว่านั่น ผมขอเริ่มจาก reference ก่อน

Reference

สมมติว่า เรามี struct นึง หน้าตาแบบนี้

struct Point {
   float x;
   float y;
}
Enter fullscreen mode Exit fullscreen mode

และเรามีโค๊ดแบบข้างล่างนี้

Point p{ .x = 100.0f, .y = 200.0f}; // Designated Initializer ฟีเจอร์ใหม่ของ C++20

float &x = p.x;
Enter fullscreen mode Exit fullscreen mode

คนที่เป็นเจ้าของ 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! 
Enter fullscreen mode Exit fullscreen mode

เป็นโค๊ดที่น่าเกลียดน่าดู (ฮา) คือ เนื่องจาก 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 ชี้ไปก็จะถูกทำลายไปด้วย
Enter fullscreen mode Exit fullscreen mode

ลักษรณะเฉพาะอันนึงที่ทำให้มัน unique คือ คุณไม่สามารถสร้างสำเนาของมันได้ วิธีเดียวที่จะสร้างสำเนาได้คือใช้ get() เช่น

std::unique_ptr<int> p1 = std::make_unique<int>(2000);
std::unique_ptr<int> p2(p1.get());
Enter fullscreen mode Exit fullscreen mode

ซึ่งคงไม่ต้องให้บอกว่า หายนะกำลังรอคุณอยู่ ....

free(): double free detected in tcache 2
timeout: the monitored command dumped core
Enter fullscreen mode Exit fullscreen mode

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 และ ค่าที่ชี้ไปก็ถูกทำลายด้วย
Enter fullscreen mode Exit fullscreen mode

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; // ถึงจะล็อคไม่ได้ โปรแกรมก็ยังไม่พัง ก็ยังหายใจกันต่อ
}
Enter fullscreen mode Exit fullscreen mode
200
failed to lock.
Enter fullscreen mode Exit fullscreen mode

ผมคิดว่า 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 ได้มากขึ้นครับ

Reference

Dynamic Memory Management - cppreference.com

Top comments (0)