กฎการแปลโค้ดจาก C# ไปเป็น C++: การสร้างวัตถุและการเรียกใช้งานเมธอด

บางครั้งพฤติกรรมของโค้ดที่เขียนใน C# และ C++ อาจแตกต่างกัน มาดูกันว่า CodePorting.Translator Cs2Cpp จัดการกับความแตกต่างเหล่านี้และรับประกันความถูกต้องของการแปลโค้ดอย่างไร เราจะเรียนรู้ด้วยว่าการแปลงทดสอบหน่วยนั้นดำเนินการอย่างไร

การสร้างและการเริ่มต้นวัตถุ

สำหรับการสร้างออบเจ็กต์ประเภทอ้างอิง เราใช้ฟังก์ชัน MakeObject (คล้ายกับ std::make_shared) ซึ่งสร้างออบเจ็กต์โดยใช้ตัวดำเนินการ ใหม่ และล้อมไว้ใน SharedPtr ทันที การใช้ฟังก์ชันนี้ช่วยให้เราหลีกเลี่ยงปัญหาที่เกิดจากพอยน์เตอร์แบบดิบได้ แต่ฟังก์ชันนี้ได้สร้างปัญหาเกี่ยวกับสิทธิ์การเข้าถึง เนื่องจากฟังก์ชันนี้อยู่นอกคลาสทั้งหมด จึงไม่สามารถเข้าถึงตัวสร้างส่วนตัวได้ การประกาศฟังก์ชันนี้ในฐานะเพื่อนของคลาสที่มีตัวสร้างที่ไม่ใช่แบบสาธารณะจะทำให้สามารถสร้างคลาสดังกล่าวได้ในทุกบริบท ด้วยเหตุนี้ เวอร์ชันภายนอกของฟังก์ชันนี้จึงถูกจำกัดให้ใช้กับตัวสร้างสาธารณะ และมีการเพิ่มเมธอด MakeObject แบบคงที่สำหรับตัวสร้างที่ไม่ใช่สาธารณะ โดยมีระดับการเข้าถึงเดียวกันและอาร์กิวเมนต์เดียวกันกับตัวสร้างพร็อกซี

โปรแกรมเมอร์ C# มักใช้คุณสมบัติเริ่มต้นเป็นส่วนหนึ่งของนิพจน์การสร้างวัตถุ ไวยากรณ์ที่เกี่ยวข้องจะต้องถูกรวมไว้ในฟังก์ชัน lambda เพราะไม่เช่นนั้น จะไม่สามารถเขียนตัวเตรียมใช้งานให้เป็นส่วนหนึ่งของนิพจน์เดียวได้:

Foo(new MyClass() { Property1 = "abc", Property2 = 1, Field1 = 3.14 });
Foo([&]{ auto tmp_0 = System::MakeObject<MyClass>();
        tmp_0->set_Property1(u"abc");
        tmp_0->set_Property2(1);
        tmp_0->Field1 = 3.14;
        return tmp_0;
    }());

การเรียกใช้งาน, ตัวแทน, และวิธีการที่ไม่ระบุชื่อ

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

class MyClass<T>
{
    public void Foo(string s) { }
    public void Bar(string s) { }
    public void Bar(bool b) { }
    public void Call()
    {
        Foo("abc");
        Bar("def");
    }
}

หลังจากการแปล มันจะดูเป็นแบบนี้:

template<typename T>
class MyClass : public System::Object
{
public:
    void Foo(System::String s)
    {
        CODEPORTING_UNUSED(s);
    }
    void Bar(System::String s)
    {
        CODEPORTING_UNUSED(s);
    }
    void Bar(bool b)
    {
        CODEPORTING_UNUSED(b);
    }
    void Call()
    {
        Foo(u"abc");
        Bar(System::String(u"def"));
    }
};

หมายเหตุ: การเรียกใช้งานเมธอด Foo และ Bar ภายในเมธอด Call ถูกเขียนอย่างแตกต่างกัน นั่นเป็นเพราะหากไม่มีการเรียกใช้งาน System::String อย่างชัดเจน การโอเวอร์โหลด Bar ที่ยอมรับ bool จะถูกเรียกใช้งาน เนื่องจากการแปลงชนิดนั้นมีความสำคัญสูงตามกฎของ C++ ในกรณีของเมธอด Foo ไม่มีความไม่ชัดเจนดังกล่าว และตัวแปลจะสร้างโค้ดที่ง่ายกว่า

ตัวอย่างอื่นของ C# และ C++ ที่มีพฤติกรรมแตกต่างกันคือการขยายแม่แบบ ใน C# การแทนที่พารามิเตอร์ประเภทจะเกิดขึ้นในระหว่างรันไทม์และไม่ส่งผลต่อการแก้ไขการเรียกใช้งานภายในเมธอดทั่วไป ใน C++ การแทนที่อาร์กิวเมนต์แม่แบบเกิดขึ้นในระหว่างเวลาคอมไพล์ ดังนั้นจึงต้องเลียนแบบพฤติกรรมของ C# ตัวอย่างเช่น พิจารณาโค้ดต่อไปนี้:

class GenericMethods
{
    public void Foo<T>(T value) { }
    public void Foo(string s) { }
    public void Bar<T>(T value)
    {
        Foo(value);
    }
    public void Call()
    {
        Bar("abc");
    }
}
class GenericMethods : public System::Object
{
public:
    template <typename T>
    void Foo(T value)
    {
        CODEPORTING_UNUSED(value);
    }
    void Foo(System::String s);
    template <typename T>
    void Bar(T value)
    {
        Foo<T>(value);
    }
    void Call();
};
void GenericMethods::Foo(System::String s)
{
}
void GenericMethods::Call()
{
    Bar<System::String>(u"abc");
}

ที่นี่ สำคัญที่จะต้องทราบถึงการระบุอาร์กิวเมนต์แม่แบบอย่างชัดเจนเมื่อเรียกใช้งาน Foo และ Bar ในกรณีแรก สิ่งนี้จำเป็นเพราะมิฉะนั้น เมื่อสร้างอินสแตนซ์สำหรับ T=System::String จะเรียกใช้งานเวอร์ชันที่ไม่ใช่แม่แบบ ซึ่งแตกต่างจากพฤติกรรมของ C# ในกรณีที่สอง ต้องการอาร์กิวเมนต์เพราะมิฉะนั้นจะถูกสรุปตามชนิดของตัวอักษรที่กำหนด โดยทั่วไปแล้ว ตัวแปลจะต้องระบุอาร์กิวเมนต์แม่แบบอย่างชัดเจนเพื่อหลีกเลี่ยงพฤติกรรมที่ไม่คาดคิด

ในหลายกรณี ตัวแปลต้องสร้างการเรียกใช้งานอย่างชัดเจนที่ไม่มีอยู่ใน C# – สิ่งนี้ส่วนใหญ่เกี่ยวข้องกับวิธีการเข้าถึงคุณสมบัติและดัชนี การเรียกใช้งานตัวสร้างของประเภทอ้างอิงจะถูกห่อหุ้มใน MakeObject ตามที่แสดงไว้ข้างต้น

ใน .NET มีเมธอดที่สนับสนุนการโอเวอร์โหลดโดยจำนวนและชนิดของอาร์กิวเมนต์ผ่านไวยากรณ์ params โดยระบุ object เป็นชนิดของอาร์กิวเมนต์ หรือทั้งสองอย่างพร้อมกัน – ตัวอย่างเช่น มีการโอเวอร์โหลดดังกล่าวสำหรับ StringBuilder.Append() และ Console.WriteLine() การถ่ายโอนโครงสร้างดังกล่าวโดยตรงแสดงให้เห็นถึงประสิทธิภาพที่ต่ำเนื่องจากการบ็อกซ์และการสร้างอาร์เรย์ชั่วคราว ในกรณีเช่นนี้ เราเพิ่มการโอเวอร์โหลดที่ยอมรับจำนวนอาร์กิวเมนต์ที่แปรผันได้ของชนิดต

ผู้รับมอบสิทธิ์จะถูกแปลเป็นความเชี่ยวชาญพิเศษของเทมเพลต MulticastDelegate ซึ่งโดยทั่วไปจะมีคอนเทนเนอร์ของอินสแตนซ์ std::function อยู่ข้างใน การภาวนา การจัดเก็บ และการมอบหมายงานของพวกเขานั้นดำเนินการเพียงเล็กน้อย วิธีการที่ไม่เปิดเผยตัวตนจะกลายเป็นฟังก์ชันแลมบ์ดา

เมื่อสร้างฟังก์ชัน lambda จำเป็นต้องยืดอายุของตัวแปรและอาร์กิวเมนต์ที่บันทึกไว้ ซึ่งทำให้โค้ดซับซ้อน ดังนั้นนักแปลจึงทำเช่นนี้เฉพาะในกรณีที่ฟังก์ชัน lambda มีโอกาสที่จะอยู่ได้นานกว่าบริบทโดยรอบ พฤติกรรมนี้ (การยืดอายุการใช้งานของตัวแปร การจับภาพโดยการอ้างอิงหรือตามค่า) สามารถควบคุมได้ด้วยตนเองเพื่อให้ได้โค้ดที่เหมาะสมที่สุด

ข้อยกเว้น

การเลียนแบบพฤติกรรม C# ในแง่ของการจัดการข้อยกเว้นนั้นค่อนข้างไม่สำคัญ เนื่องจากข้อยกเว้นใน C# และ C++ มีพฤติกรรมแตกต่างออกไป:

  • ใน C# ข้อยกเว้นจะถูกสร้างขึ้นบนฮีปและถูกลบโดยตัวรวบรวมขยะ
  • ใน C++ ข้อยกเว้นจะถูกคัดลอกระหว่างสแต็กและพื้นที่หน่วยความจำเฉพาะในเวลาที่ต่างกัน

สิ่งนี้นำเสนอความขัดแย้ง หากประเภทข้อยกเว้น C# ได้รับการแปลเป็นประเภทอ้างอิง โดยทำงานร่วมกับพอยน์เตอร์ดิบ (throw new ArgumentException) จะทำให้หน่วยความจำรั่วหรือเกิดปัญหาสำคัญในการพิจารณาจุดลบ หากแปลเป็นประเภทการอ้างอิงแต่เป็นเจ้าของผ่านตัวชี้อัจฉริยะ (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())) ข้อยกเว้นจะไม่สามารถตรวจจับได้จากประเภทพื้นฐานเนื่องจาก SharedPtr<ArgumentException> ไม่ได้สืบทอด จาก SharedPtr<Exception> อย่างไรก็ตาม หากวางวัตถุข้อยกเว้นไว้บนสแต็ก วัตถุเหล่านั้นจะถูกจับอย่างถูกต้องตามประเภทฐาน แต่เมื่อบันทึกในตัวแปรของประเภทฐาน ข้อมูลเกี่ยวกับประเภทสุดท้ายจะถูกตัดทอน

เพื่อแก้ไขปัญหานี้ เราได้สร้างตัวชี้อัจฉริยะชนิดพิเศษ ExceptionWrapper คุณลักษณะที่สำคัญของมันคือหากคลาส ArgumentException สืบทอดมาจาก Exception ดังนั้น ExceptionWrapper<ArgumentException> ก็สืบทอดมาจาก ExceptionWrapper<Exception> ด้วยเช่นกัน อินสแตนซ์ของ ExceptionWrapper ใช้เพื่อจัดการอายุการใช้งานของอินสแตนซ์คลาสข้อยกเว้น และการตัดทอนประเภท ExceptionWrapper จะไม่นำไปสู่การตัดทอนประเภท Exception ที่เกี่ยวข้อง การขว้างข้อยกเว้นได้รับการจัดการโดยวิธีการเสมือน ซึ่งถูกแทนที่โดยลูกหลาน Exception ซึ่งสร้าง ExceptionWrapper ที่กำหนดพารามิเตอร์ตามประเภทข้อยกเว้นสุดท้ายแล้วโยนทิ้ง ลักษณะเสมือนช่วยให้สามารถโยนประเภทข้อยกเว้นที่ถูกต้องได้ แม้ว่าประเภท ExceptionWrapper จะถูกตัดทอนก่อนหน้านี้ และการเชื่อมโยงระหว่างวัตถุข้อยกเว้นกับ ExceptionWrapper จะป้องกันการรั่วไหลของหน่วยความจำ

การทดสอบ

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

โปรแกรมเมอร์ C# ใช้เฟรมเวิร์ก NUnit และ xUnit นักแปลจะแปลงตัวอย่างการทดสอบที่เกี่ยวข้องเป็น GoogleTest โดยแทนที่ไวยากรณ์ของการตรวจสอบและวิธีการเรียกที่มีเครื่องหมาย Test หรือ Fact จากฟังก์ชันการทดสอบที่เกี่ยวข้อง รองรับทั้งการทดสอบที่ไม่มีอาร์กิวเมนต์และข้อมูลอินพุต เช่น TestCase หรือ TestCaseData ตัวอย่างของคลาสการทดสอบการแปลมีดังต่อไปนี้

[TestFixture]
class MyTestCase
{
    [Test]
    public void Test1()
    {
        Assert.AreEqual(2*2, 4);
    }
    [TestCase("123")]
    [TestCase("abc")]
    public void Test2(string s)
    {
        Assert.NotNull(s);
    }
}
class MyTestCase : public System::Object
{
public:
    void Test1();
    void Test2(System::String s);
};

namespace gtest_test
{

class MyTestCase : public ::testing::Test
{
protected:
    static System::SharedPtr<::ClassLibrary1::MyTestCase> s_instance;
    
public:
    static void SetUpTestCase()
    {
        s_instance = System::MakeObject<::ClassLibrary1::MyTestCase>();
    };
    
    static void TearDownTestCase()
    {
        s_instance = nullptr;
    };
};

System::SharedPtr<::ClassLibrary1::MyTestCase> MyTestCase::s_instance;

} // namespace gtest_test

void MyTestCase::Test1()
{
    ASSERT_EQ(2 * 2, 4);
}

namespace gtest_test
{

TEST_F(MyTestCase, Test1)
{
    s_instance->Test1();
}

} // namespace gtest_test

void MyTestCase::Test2(System::String s)
{
    ASSERT_FALSE(System::TestTools::IsNull(s));
}

namespace gtest_test
{

using MyTestCase_Test2_Args = System::MethodArgumentTuple<decltype(
    &ClassLibrary1::MyTestCase::Test2)>::type;

struct MyTestCase_Test2 : public MyTestCase, public ClassLibrary1::MyTestCase,
    public ::testing::WithParamInterface<MyTestCase_Test2_Args>
{
    static std::vector<ParamType> TestCases()
    {
        return
        {
            std::make_tuple(u"123"),
            std::make_tuple(u"abc"),
        };
    }
};

TEST_P(MyTestCase_Test2, Test)
{
    const auto& params = GetParam();
    ASSERT_NO_FATAL_FAILURE(s_instance->Test2(std::get<0>(params)));
}

INSTANTIATE_TEST_SUITE_P(, MyTestCase_Test2, 
    ::testing::ValuesIn(MyTestCase_Test2::TestCases()));

} // namespace gtest_test

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