20 กุมภาพันธ์ 2568
เมื่อพัฒนาตัวแปลงโค้ดจาก C# ไปยัง Java จะไม่มีปัญหาเกี่ยวกับการลบวัตถุที่ไม่ได้ใช้งาน: Java มีกลไกการเก็บขยะ (garbage collection) ที่คล้ายคลึงกับของ C# เพียงพอ และโค้ดที่แปลงแล้วซึ่งใช้คลาสจะสามารถคอมไพล์และทำงานได้โดยตรง C++ เป็นอีกเรื่องหนึ่ง การแมปกันระหว่างการอ้างอิงไปยังตัวชี้เปล่า (raw pointers) จะไม่ให้ผลลัพธ์ตามที่ต้องการ เนื่องจากโค้ดที่แปลงแล้วเช่นนั้นจะไม่ลบอะไรเลย ในขณะเดียวกัน นักพัฒนา C# ที่คุ้นเคยกับการทำงานในสภาพแวดล้อมที่มี garbage collector (GC) จะยังคงเขียนโค้ดที่สร้างวัตถุชั่วคราวจำนวนมาก
เพื่อให้มั่นใจว่าวัตถุจะถูกลบอย่างทันเวลาในโค้ดที่แปลงแล้ว เราต้องเลือกจากสามตัวเลือกดังนี้:
ตัวเลือกที่สามถูกปฏิเสธทันที: ความซับซ้อนของอัลกอริธึมในไลบรารีที่ถูกย้ายพิสูจน์ได้ว่าเป็นอุปสรรค นอกจากนี้ การวิเคราะห์แบบคงที่จำเป็นต้องขยายไปถึงโค้ดลูกค้าที่ใช้ไลบรารีที่แปลงแล้วเหล่านี้
ตัวเลือกที่สองก็ปรากฏว่าไม่เหมาะสม: เนื่องจากเรากำลังย้ายไลบรารีแทนที่จะเป็นแอปพลิเคชัน การบังคับใช้ตัวเก็บขยะจะทำให้เกิดข้อจำกัดต่อโค้ดลูกค้าที่ใช้ไลบรารีเหล่านี้ การทดลองในทิศทางนี้ถูกพิจารณาว่าล้มเหลว
ดังนั้น เราจึงมาถึงตัวเลือกสุดท้ายที่เหลืออยู่—การใช้ตัวชี้อัจฉริยะพร้อมการนับการอ้างอิง ซึ่งเป็นสิ่งที่พบได้ทั่วไปใน C++ สิ่งนี้หมายความว่าเพื่อแก้ปัญหาการอ้างอิงแบบวงจร เราจำเป็นต้องใช้อ้างอิงแบบอ่อนแอ (weak references) ควบคู่กับอ้างอิงแบบแข็งแกร่ง (strong references)
มีตัวชี้อัจฉริยะหลายประเภทที่เป็นที่รู้จักกันดี:
shared_ptr
อาจดูเหมือนเป็นตัวเลือกที่ชัดเจนที่สุด แต่มีข้อเสียสำคัญ: มันเก็บตัวนับการอ้างอิงบนฮีปแยกจากวัตถุ แม้ในกรณีที่ใช้ enable_shared_from_this
การจัดสรรและปล่อยหน่วยความจำสำหรับตัวนับการอ้างอิงเป็นการดำเนินการที่ค่อนข้างแพงintrusive_ptr
ดีกว่าในเรื่องนี้ เพราะการมีฟิลด์ขนาด 4/8 ไบต์ที่ไม่ได้ใช้งานภายในโครงสร้างเป็นความชั่วร้ายที่น้อยกว่าเมื่อเทียบกับภาระของการจัดสรรเพิ่มเติมสำหรับวัตถุชั่วคราวแต่ละตัวตอนนี้ พิจารณาโค้ด C# ต่อไปนี้:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
เมื่อใช้ intrusive_ptr
โค้ดนี้จะถูกแปลงเป็นบางสิ่งแบบนี้:
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
document = d;
d->Prepare(this);
}
};
ที่นี่ มีสามปัญหาที่เห็นได้ชัดทันที:
Node::document
เป็นอ้างอิงแบบอ่อนแอthis
ให้เป็น intrusive_ptr
(คล้ายกับ shared_from_this
) หากเราเปลี่ยนลายเซ็นเมธอด (เช่น ทำให้ Document::Prepare
รับ Node*
แทน intrusive_ptr<Node>
) จะเกิดปัญหาเมื่อเรียกเมธอดเดียวกันกับวัตถุที่สร้างเสร็จแล้วหรือการจัดการอายุวัตถุthis
ให้เป็น intrusive_ptr
ระหว่างการสร้างวัตถุ ตามด้วยการลดจำนวนการอ้างอิงลงเหลือศูนย์ (เช่น ในคอนสตรัคเตอร์ของ Node
เมื่อออกจาก Document::Prepare
) ต้องไม่ลบวัตถุที่สร้างยังไม่เสร็จ ซึ่งยังไม่มีการอ้างอิงภายนอกปัญหาแรกได้รับการแก้ไขด้วยวิธีการด้วยตนเอง เนื่องจากแม้แต่มนุษย์เองก็มักจะลำบากในการกำหนดว่าอ้างอิงใดควรเป็นอ้างอิงแบบอ่อนแอ ในบางกรณีไม่มีคำตอบที่ชัดเจน ซึ่งต้องการการเปลี่ยนแปลงโค้ด C#
ตัวอย่างเช่น ในโครงการหนึ่ง มีคู่ของคลาส: “การกระทำพิมพ์” และ “พารามิเตอร์การกระทำพิมพ์” คอนสตรัคเตอร์ของแต่ละคลาสสร้างวัตถุคู่และสร้างการอ้างอิงสองทางอย่างชัดเจน การเปลี่ยนหนึ่งในอ้างอิงเหล่านี้ให้เป็นอ้างอิงแบบอ่อนแอจะทำลายสถานการณ์การใช้งาน สุดท้าย เราตัดสินใจใช้แอตทริบิวต์ [CppWeakPtr]
เพื่อบอกตัวแปลงว่าฟิลด์ที่เกี่ยวข้องควรมีอ้างอิงแบบอ่อนแอแทนที่จะเป็นอ้างอิงแบบแข็งแกร่ง
ปัญหาที่สองแก้ไขได้ง่ายหาก intrusive_ptr
อนุญาตให้แปลงจากตัวชี้ดิบ (raw pointer) ซึ่ง this
ก็คือตัวชี้ดิบ การนำเสนอนี้มาจาก Boost
สุดท้าย ปัญหาที่สามได้รับการแก้ไขโดยการแนะนำตัวแปร RAII guard ท้องถิ่นในคอนสตรัคเตอร์ การ์ดนี้จะเพิ่มจำนวนการอ้างอิงของวัตถุปัจจุบันเมื่อสร้างและลดลงเมื่อทำลาย ที่สำคัญ การลดจำนวนการอ้างอิงลงเหลือศูนย์ภายในการ์ดจะไม่ลบวัตถุที่ได้รับการป้องกัน
ด้วยการเปลี่ยนแปลงเหล่านี้ โค้ดก่อนและหลังการแปลงจะมีลักษณะประมาณนี้:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
[CppWeakPtr] private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
System::Details::ThisProtector guard(this);
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
weak_intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
System::Details::ThisProtector guard(this);
document = d;
d->Prepare(this);
}
};
ดังนั้น ตราบใดที่การนำ intrusive_ptr
ใด ๆ มาใช้ตอบสนองความต้องการของเราและเสริมด้วยคลาส weak_intrusive_ptr
ที่จับคู่กัน มันก็จะเพียงพอ อย่างหลังต้องอาศัยตัวนับการอ้างอิงที่ตั้งอยู่บนฮีปนอกวัตถุ เนื่องจากการสร้างอ้างอิงแบบอ่อนแอเป็นการดำเนินการที่ค่อนข้างหายากเมื่อเทียบกับการสร้างวัตถุชั่วคราว การแยกตัวนับการอ้างอิงออกเป็นตัวนับแบบแข็งแกร่ง (ภายในวัตถุ) และตัวนับแบบอ่อนแอ (นอกวัตถุ) ให้ผลดีด้านประสิทธิภาพในโค้ดโลกแห่งความเป็นจริง
สถานการณ์ซับซ้อนขึ้นอย่างมากเนื่องจากเราจำเป็นต้องแปลงโค้ดสำหรับคลาสและเมธอดทั่วไป (generic classes and methods) ซึ่งพารามิเตอร์ชนิดสามารถเป็นชนิดค่าหรือชนิดอ้างอิงได้ ตัวอย่างเช่น พิจารณาโค้ด C# ต่อไปนี้:
class MyContainer<T>
{
public T field;
public void Set(T val)
{
field = val;
}
}
class MyClass {}
struct MyStruct {}
var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();
แนวทางการย้ายแบบตรงไปตรงมาให้ผลลัพธ์ดังนี้:
template <typename T> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};
auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();
ชัดเจนว่าโค้ดนี้จะไม่ทำงานเหมือนกับต้นฉบับ เนื่องจากเมื่อสร้างอินสแตนซ์ MyContainer<MyClass>
วัตถุ field
จะเคลื่อนจากฮีปเข้าไปในฟิลด์ของ MyContainer
ทำลายความหมายของการคัดลอกการอ้างอิง ในขณะเดียวกัน การวางโครงสร้าง MyStruct
ในฟิลด์นั้นถูกต้องทั้งหมด เนื่องจากสอดคล้องกับพฤติกรรมของ C#
สถานการณ์นี้สามารถแก้ไขได้สองวิธี:
MyContainer<MyClass>
ไปยังความหมายของ MyContainer<intrusive_ptr<MyClass>>
:auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
template <typename T> class MyContainer<T, true> : public virtual System::Object
{
public:
intrusive_ptr<T> field;
void Set(intrusive_ptr<T> val)
{
field = val;
}
};
นอกจากความยืดยาวที่เพิ่มขึ้นแบบทวีคูณเมื่อมีพารามิเตอร์ชนิดเพิ่มเติม แนวทางที่สองยังมีข้อเสียที่ว่าทุกบริบทที่ใช้ MyContainer<T>
จะต้องทราบว่า T
เป็นชนิดค่าหรือชนิดอ้างอิง ซึ่งมักจะไม่พึงประสงค์ เช่น ในกรณีที่เราต้องการลดจำนวนของไฟล์ส่วนหัว (headers) ที่รวมเข้ามา หรือซ่อนข้อมูลเกี่ยวกับชนิดภายในบางอย่างโดยสมบูรณ์
ยิ่งไปกว่านั้น การเลือกชนิดของการอ้างอิง (แบบแข็งแกร่งหรือแบบอ่อนแอ) สามารถทำได้เพียงครั้งเดียวต่อหนึ่งคอนเทนเนอร์ หมายความว่าจะไม่สามารถมีทั้งรายการ (List
) ของอ้างอิงแบบแข็งแกร่งและรายการของอ้างอิงแบบอ่อนแอได้ แม้ว่าโค้ดของผลิตภัณฑ์ที่แปลงแล้วจะต้องการทั้งสองรูปแบบก็ตาม
เมื่อพิจารณาจากปัจจัยเหล่านี้ จึงได้ตัดสินใจแปลง MyContainer<MyClass>
โดยใช้แนวคิดของ MyContainer<System::SharedPtr<MyClass>>
สำหรับอ้างอิงแบบแข็งแกร่ง หรือ MyContainer<System::WeakPtr<MyClass>>
สำหรับอ้างอิงแบบอ่อนแอ เนื่องจากไลบรารียอดนิยมส่วนใหญ่ไม่มีตัวชี้ที่มีลักษณะตามที่ต้องการ เราจึงได้พัฒนาการนำของเราเองขึ้นมา โดยมีชื่อว่า System::SharedPtr
—เป็นอ้างอิงแบบแข็งแกร่งที่ใช้ตัวนับการอ้างอิงภายในวัตถุ—และ System::WeakPtr
—เป็นอ้างอิงแบบอ่อนแอที่ใช้ตัวนับการอ้างอิงภายนอกวัตถุ ฟังก์ชัน System::MakeObject
มีหน้าที่ในการสร้างวัตถุในสไตล์เดียวกับ std::make_shared