20 กุมภาพันธ์ 2568

การพอร์ตโค้ด C# ไปเป็น C++: ตัวชี้อัจฉริยะ

เมื่อพัฒนาตัวแปลงโค้ดจาก C# ไปยัง Java จะไม่มีปัญหาเกี่ยวกับการลบวัตถุที่ไม่ได้ใช้งาน: Java มีกลไกการเก็บขยะ (garbage collection) ที่คล้ายคลึงกับของ C# เพียงพอ และโค้ดที่แปลงแล้วซึ่งใช้คลาสจะสามารถคอมไพล์และทำงานได้โดยตรง C++ เป็นอีกเรื่องหนึ่ง การแมปกันระหว่างการอ้างอิงไปยังตัวชี้เปล่า (raw pointers) จะไม่ให้ผลลัพธ์ตามที่ต้องการ เนื่องจากโค้ดที่แปลงแล้วเช่นนั้นจะไม่ลบอะไรเลย ในขณะเดียวกัน นักพัฒนา C# ที่คุ้นเคยกับการทำงานในสภาพแวดล้อมที่มี garbage collector (GC) จะยังคงเขียนโค้ดที่สร้างวัตถุชั่วคราวจำนวนมาก

เพื่อให้มั่นใจว่าวัตถุจะถูกลบอย่างทันเวลาในโค้ดที่แปลงแล้ว เราต้องเลือกจากสามตัวเลือกดังนี้:

  1. ใช้นับการอ้างอิงสำหรับวัตถุ เช่น ผ่านตัวชี้อัจฉริยะ (smart pointers);
  2. ใช้การนำการเก็บขยะ (garbage collector) มาใช้สำหรับ C++ เช่น Boehm GC;
  3. ใช้การวิเคราะห์แบบคงที่ (static analysis) เพื่อกำหนดตำแหน่งที่วัตถุควรจะถูกลบ

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

ตัวเลือกที่สองก็ปรากฏว่าไม่เหมาะสม: เนื่องจากเรากำลังย้ายไลบรารีแทนที่จะเป็นแอปพลิเคชัน การบังคับใช้ตัวเก็บขยะจะทำให้เกิดข้อจำกัดต่อโค้ดลูกค้าที่ใช้ไลบรารีเหล่านี้ การทดลองในทิศทางนี้ถูกพิจารณาว่าล้มเหลว

ดังนั้น เราจึงมาถึงตัวเลือกสุดท้ายที่เหลืออยู่—การใช้ตัวชี้อัจฉริยะพร้อมการนับการอ้างอิง ซึ่งเป็นสิ่งที่พบได้ทั่วไปใน 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);
    }
};

ที่นี่ มีสามปัญหาที่เห็นได้ชัดทันที:

  1. ต้องมีกลไกในการทำลายการอ้างอิงแบบวงจร โดยในกรณีนี้การทำให้ Node::document เป็นอ้างอิงแบบอ่อนแอ
  2. ต้องมีวิธีแปลง this ให้เป็น intrusive_ptr (คล้ายกับ shared_from_this) หากเราเปลี่ยนลายเซ็นเมธอด (เช่น ทำให้ Document::Prepare รับ Node* แทน intrusive_ptr<Node>) จะเกิดปัญหาเมื่อเรียกเมธอดเดียวกันกับวัตถุที่สร้างเสร็จแล้วหรือการจัดการอายุวัตถุ
  3. การแปลง 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#

สถานการณ์นี้สามารถแก้ไขได้สองวิธี:

  1. เปลี่ยนจากความหมายของ MyContainer<MyClass> ไปยังความหมายของ MyContainer<intrusive_ptr<MyClass>>:
auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
  1. สร้างการเฉพาะเจาะจงสองแบบสำหรับแต่ละคลาสเตมเพลต: หนึ่งสำหรับกรณีที่อาร์กิวเมนต์ชนิดเป็นชนิดค่า และอีกหนึ่งสำหรับกรณีที่เป็นชนิดอ้างอิง:
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

ข่าวที่เกี่ยวข้อง

วิดีโอที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง