C#からC++へのコード変換ルール:オブジェクトの作成とメソッド呼び出し

C#とC++で書かれたコードの動作は異なる場合があります。CodePorting.Translator Cs2Cppがこのような違いをどのように扱い、コード変換の正確性を保証するかを詳しく見ていきましょう。また、ユニットテストの変換がどのように行われるかも学びます。

オブジェクトの作成と初期化

参照型オブジェクトを作成するために、MakeObject関数(std::make_sharedに相当)を使用します。これはnew演算子を使用してオブジェクトを作成し、直ちにSharedPtrでラップします。この関数を使用することで、生ポインターによって導入された問題を避けることができましたが、アクセス権の問題が発生しました:すべてのクラスの外にあるため、プライベートコンストラクターへのアクセスができませんでした。この関数を非公開コンストラクターを持つクラスのフレンドとして宣言することで、すべてのコンテキストでそのようなクラスを作成することが可能になりました。その結果、この関数の外部バージョンは公開コンストラクターでの使用に限定され、非公開コンストラクター用に同じアクセスレベルとプロキシされたコンストラクターと同じ引数を持つ静的MakeObjectメソッドが追加されました。

C#プログラマーは、オブジェクト作成式の一部としてプロパティ初期化子をよく使用します。対応する構文は、それを単一の式の一部として書くことができないため、ラムダ関数でラップする必要があります:

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"));
    }
};

注:Callメソッド内のFooBarへのメソッド呼び出しは異なって記述されています。これは、System::Stringコンストラクタへの明示的な呼び出しがなければ、C++ のルールによりboolを受け取るBarのオーバーロードが呼び出されるためです。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");
}

ここで重要なのは、FooBarを呼び出す際にテンプレート引数を明示的に指定することです。最初のケースでは、そうしないと、T=System::Stringのバージョンをインスタンス化する際に、C#の振る舞いとは異なる非テンプレートバージョンが呼び出されるためです。二番目のケースでは、引数が必要です。そうでなければ、文字列リテラルの型に基づいて推論されます。一般的に、トランスレータは予期しない振る舞いを避けるために、ほとんど常にテンプレート引数を明示的に指定する必要があります。

多くの場合、トランスレータはC#にはない明示的な呼び出しを生成する必要があります。これは主に、プロパティやインデクサへのアクセスメソッドに関するものです。参照型のコンストラクタの呼び出しは、上記のようにMakeObjectでラップされます。

.NETには、StringBuilder.Append()Console.WriteLine()など、params構文を使用して引数の数と型によるオーバーロードをサポートするメソッドがあります。このような構造の直接転送は、ボックス化と一時配列の作成のためにパフォーマンスが低下します。そのため、可変数の引数を任意の型で受け取るオーバーロードを追加し、型キャストや配列への統合なしに引数をそのまま翻訳します。その結果、このような呼び出しのパフォーマンスが向上します。

デリゲートはMulticastDelegateテンプレートの特殊化に翻訳され、通常はstd::functionインスタンスのコンテナを内部に含んでいます。その呼び出し、格納、および割り当ては簡単に行われます。匿名メソッドはラムダ関数に変換されます。

ラムダ関数を作成する際には、キャプチャされた変数と引数の寿命を延ばす必要があり、これによりコードが複雑になるため、トランスレータはラムダ関数が周囲のコンテキストを超えて生き残る可能性がある場合にのみこれを行います。この振る舞い(変数の寿命を延ばす、参照によるキャプチャまたは値によるキャプチャ)は、より最適なコードを得るために手動で制御することもできます。

例外

例外処理に関してC#の挙動をエミュレートすることは、決して簡単なことではありません。これは、C#とC++で例外の挙動が異なるためです:

  • C#では、例外はヒープ上に作成され、ガベージコレクターによって削除されます。
  • C++では、例外はスタックと専用のメモリ領域の間で異なるタイミングでコピーされます。

これは矛盾を生じさせます。もしC#の例外型が参照型として変換され、生ポインター(throw new ArgumentException)を介して扱われる場合、メモリリークや削除ポイントの特定に関する重大な問題を引き起こす可能性があります。もし参照型として変換され、スマートポインター(throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>()))を介して所有される場合、SharedPtr<ArgumentException>SharedPtr<Exception>から継承していないため、基本型によって例外をキャッチすることはできません。しかし、例外オブジェクトがスタック上に置かれる場合、基本型によって正しくキャッチされますが、基本型の変数に保存されると、最終型に関する情報が切り捨てられます。

この問題を解決するために、特別なタイプのスマートポインターExceptionWrapperを作成しました。その主な特徴は、もしクラスArgumentExceptionExceptionから継承している場合、ExceptionWrapper<ArgumentException>ExceptionWrapper<Exception>から継承するということです。ExceptionWrapperのインスタンスは、例外クラスのインスタンスのライフタイムを管理するために使用され、ExceptionWrapper型の切り捨ては、関連するException型の切り捨てにはつながりません。例外の投げる処理は、最終的な例外型でパラメータ化されたExceptionWrapperを作成し、それを投げるExceptionの子孫によってオーバーライドされた仮想メソッドによって処理されます。仮想的な性質により、ExceptionWrapper型が以前に切り捨てられていたとしても、正しいタイプの例外を投げることができ、例外オブジェクトとExceptionWrapperとのリンクはメモリリークを防ぎます。

テスト

私たちのフレームワークの強みの一つは、ソースコードだけでなく、それに対する単体テストも変換できる能力です。

C#プログラマーはNUnitとxUnitフレームワークを使用します。翻訳者は、対応するテスト例をGoogleTestに変換し、TestまたはFactフラグでマークされたメソッドの構文のチェックと呼び出しを、それぞれのテスト関数から置き換えます。引数のないテストとTestCaseTestCaseDataのような入力データの両方がサポートされています。以下に翻訳されたテストクラスの例を示します。

[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

関連記事