レガシー コードのモダナイゼーション

過去 15 年間、私は 90 年代初頭に始まった大規模なレガシー コード ベースを扱ってきました。そのため、未加工のポインター、無効ポインター、すべての変数を使用前に宣言する、どこからでもアクセスできるパブリック データ メンバーなど、古いスタイルと規則を使用する多くのコードを処理する必要がありました。私は変化を信じているので、できるだけ多くの変化を起こそうとしています。もちろん、これは常に可能または望ましいとは限りません (さまざまな制約のため)。さらに、コードを最新化するために大規模なプロジェクトを数か月または数年にわたって停止する人はいません。ただし、小さいながらも段階的な変更を適用することは常に可能であり、時間の経過とともに大きなコード ベースが改善される可能性があります。これは、変更が必要なコードの部分に常に適用している戦略です。このブログ投稿では、古い C++ コードをモダナイズして改善するためにできる一連の改善点をリストアップします。

マクロ

定数にマクロを使用するのは非常に一般的なケースです。以下は、いくつかのプリンターの種類を定義する例です:

#define PRINT_STANDARD  0
#define PRINT_PDF       1
#define PRINT_TEXT      2

プリンターの種類が必要な場合に、これらの値 (この例では 0、1、および 2) の使用を制限する方法がないため、これは良くありません:

void print(document doc, int const printer)
{
  // ...
}
 
print(doc, 42);                 // oops

この場合の最善の方法は、範囲指定された列挙型を定義することです。

enum class printer_type
{
   standard = 0,
   pdf,
   text
};

printer_type を使用する int の代わりに プリンターの種類が必然的にどこであっても、常に正当な値を使用するようにすることができます。

void print(document doc, printer_type const printer)
{
  // ...
}
 
print(doc, printer_type::pdf);  // OK
print(doc, 42);                 // compiler-error

場合によっては、列挙を表さない値を定義するためにマクロが使用されます。たとえば、バッファのサイズ:

#define BUFFER_SIZE 1024
 
int main() 
{
   char buffer[BUFFER_SIZE];
}

この場合、最善の解決策は constexpr を定義することです

constexpr size_t BUFFER_SIZE = 1024;

関数のようなマクロもあります。以下に例を示します:

#define MEGA_BYTES(MB) (MB * 1048576)

enum class GROW_BY
{
  mb1 = MEGA_BYTES (1),
  mb4 = MEGA_BYTES (4),
  mb8 = MEGA_BYTES (8)
};

この種のマクロは constexpr に置き換えることができます 関数。方法は次のとおりです:

constexpr auto mega_bytes(unsigned const mb)
{
    return mb * 1048576;
}

enum class GROW_BY
{
  mb1 = mega_bytes(1),
  mb4 = mega_bytes(4),
  mb8 = mega_bytes(8)
};

C++20 では、mega_bytes() 関数は、代わりに即時関数にすることができます。即時関数は、コンパイル時の定数を生成する必要がある関数です。このような関数は、コンパイル時にのみ存在します。シンボルは発行されず、そのアドレスを取得することはできません。したがって、即値関数はマクロに非常に似ています。 consteval で即時関数が宣言されている キーワード (constexpr とは併用できません) )。これが mega_bytes() のやり方です 関数は C++20 で宣言できます:

consteval auto mega_bytes(unsigned const mb)
{
   return mb * 1048576;
}

次の記事で、定数、マクロ、代替手段について詳しく読むことができます:

  • プリプロセッサを回避するための 5 つの例
  • 定数がありますように!

型エイリアス

私は、C でのプログラミングのバックグラウンドを持つ人々が使用する次の構造定義スタイルを必要以上に見てきました:

typedef struct TransInfo
{
   INT iUniqueNo;
   INT iDocNo;
} TRANSINFO, *PTRANSINFO;

typedef struct 必ずしも C++ である必要はありません。したがって、C++ の定義は次のようになります。

struct TRANSINFO
{
   INT iUniqueNo;
   INT iDocNo;
};

typedef TRANSINFO* PTRANSINFO;

しかし、もっとうまくやることができます。 C++11 は、型のエイリアスを定義できる型エイリアスを提供するため、より読みやすい方法です。上記の typedef は次のものと同等です:

using PTRANSINFO = TRANSINFO*;

これは、関数ポインターを定義する必要がある場合により関連性があります。次の関数があるとしましょう foo() そして foobar() および 3 番目の関数 doit() 次の 2 つの関数のいずれかのアドレスを取得する必要があります:

bool foo(int const a, double const b)
{
    // ...
}

bool foobar(int a, double b)
{
    // ...
}

void doit(fp_foo f)
{
    std::cout << f(42, 100) << '\n';
}

int main()
{
    doit(foo);
    doit(foobar);
}

では、関数ポインタ型 fn_foo をどのように定義しますか? ?私は、これを行うための構文を覚えるのに苦労したことを告白しなければなりません。私はいつもそれを見なければなりませんでした。方法は次のとおりです:

typedef bool (*fp_foo)(int const, double const);

ただし、using 定義構文を使用すると、はるかに読みやすく覚えやすい定義を記述できます。

using fp_foo = bool(*)(int const, double const);

これは、std::function の宣言に使用される構文と同じです。 オブジェクト、(*) を除く 部。以下に例を示します:

void doit(std::function<bool(int const, double const)> f)
{
    std::cout << f(42, 100) << '\n';
}

たとえば、Windows API への関数ポインターを定義している場合に、呼び出し規約を指定する必要がある場合はどうすればよいでしょうか? typedef の使用 、次の構文が必要です:

typedef BOOL (WINAPI *AdjustWindowRectExForDpi_fn)(LPRECT, DWORD, BOOL, DWORD, UINT);

ただし、using では 宣言は次のように変更されます:

using AdjustWindowRectExForDpi_fn = BOOL(WINAPI *)(LPRECT, DWORD, BOOL, DWORD, UINT);

呼び出し規約 (WINAPI これは __stdcall を表すマクロです ) は、(WINAPI *) のように、戻り値の型とパラメーター型のリストの間の宣言の途中に配置されます。 .

typedef を使用する方が自然に読み書きできるので、しばらく前に typedef の使用をやめました。

データ メンバーの初期化

データ メンバーの初期化で次のパターンに数えきれないほど遭遇しました:

struct TRANSINFO
{
   INT iUniqueNo;
   INT iDocNo;
   // ...

   TRANSINFO();
};

TRANSINFO::TRANSINFO()
{
   iUniqueNo = 0;
   iDocNo = 0;
}

データ メンバーの初期化は初期化リストで行う必要があるため、これは誤りです (これが不可能なビューの場合を除く)。上記のようにすると、各メンバーが 2 回初期化されます (int などの組み込みの数値型では意味がない場合があります)。 ただし、これはより大きなオブジェクト用です)。これは、コンストラクター本体が実行される前に、すべての直接ベース、仮想ベース、および非静的データ メンバーの初期化が実行されるためです。非静的データ メンバーに対してデフォルト以外の初期化を指定する場合は、初期化リストを使用する必要があります。方法は次のとおりです:

TRANSINFO::TRANSINFO() : iUniqueNo(0), iDocNo(0)
{
}

リスト内の初期化の順序は重要ではないことに注意してください。非静的データ メンバは、クラス定義の宣言順に初期化されます。

問題は、クラスのデータ メンバーが多いほど、メンバーの初期化を忘れる可能性が高くなることです。 C++11 では、データ メンバーの宣言内で提供することにより、初期化を簡素化できます。

struct TRANSINFO
{
   INT iUniqueNo = 0;
   INT iDocNo = 0;
   // ...
};

コンストラクターの引数からデータ メンバーを初期化する必要がある場合でも、コンストラクターの初期化リストを使用する必要があります。両方の初期化が存在する場合、初期化リストの 1 つが優先されます。これは、パラメーターのセットが異なる複数のコンストラクターを持つクラスに役立ちます。

メモリの割り当てと割り当て解除の回避

メモリを内部的に割り当て、オブジェクトがスコープ外に出ると自動的に割り当てを解除する標準コンテナを使用すると、メモリの明示的な割り当てと割り当て解除を回避できます。 std::vector などの標準コンテナの例 Windows システム API を呼び出すときに必要な可変サイズのバッファーに使用できます。呼び出し元から渡されたバッファーを埋める必要がある Windows API 関数は多数ありますが、呼び出し元は最初にバッファーのサイズを決定する必要があります。これは、必要なサイズを返す関数を決定するヌル バッファーを使用して関数を最初に呼び出すことによって解決されます。次に、バッファにメモリを割り当て、十分なサイズのバッファで同じ関数を呼び出します。以下は、このパターンの例です。

void GetUsernameAndDoSomething()
{
   ULONG nSize = 0;

   // determine the size of the buffer 
   if (!::GetUserName(NULL, &nSize))
   {
      if (GetLastError() != ERROR_INSUFFICIENT_BUFFER)
      {
         return;
      }
   }

   // allocate some buffer memory
   LPSTR pBuffer = new CHAR[nSize];
   if (pBuffer == NULL)
      return;

   // fill the buffer
   if (!::GetUserName(pBuffer, &nSize))
   {
      // [1] oops... failed to delete allocated memory
      return;
   }
   
   // do something
   // [2] what if it throws? oops...

   // clean up
   delete [] pBuffer;
}

このコードには 2 つの問題があります。 [1] と [2] でマークされたポイントは、メモリ リークを起こします。 [1] では、割り当てられたバッファーを削除せずに戻ります。 [2] で例外が発生したため、バッファを削除する次の行が実行されず、再びメモリ リークが発生しました。これは std::vector の助けを借りて単純化できます 次のように:

void GetUsernameAndDoSomething()
{
   ULONG nSize = 0;

   // determine the size of the buffer 
   if (!::GetUserName(nullptr, &nSize))
   {
      if (GetLastError() != ERROR_INSUFFICIENT_BUFFER)
      {
         return;
      }
   }

   // allocate some buffer memory
   std::vector<char> pBuffer(nSize);

   // fill the buffer
   if (!::GetUserName(pBuffer.data(), &nSize))
   {
      // no need to deallocate anything
      return;
   }

   // do something
   // what if it throws? we're OK
}

この新しい実装では、通常 (return ステートメントを使用) または例外が発生したために関数から戻ると、 pBuffer が オブジェクトは破棄され、その内部メモリは削除されます。したがって、この実装はより短く、より堅牢です。

この例は、バッファ (連続したメモリ チャンク) の使用に関するものです。ただし、単一のオブジェクトを割り当てて生のポインターを使用すると、同じ問題が発生します。次のスニペットを見てください:

void give_up_ownership(foo* ptr)
{
  // do something
  delete ptr;
}

void example()
{
   foo* ptr = new foo();
   
   if(...)
   {
      delete ptr;
      return;
   }
   
   if(...)
   {
      // [1] oops... failed to delete object
      return;
   }
   
   give_up_ownership(ptr);   
}

example() という関数があります foo を割り当てる 最終的に関数 give_up_ownership() に渡すオブジェクト .そうする前に、いくつかのチェックを行い、その関数を呼び出さずに戻る場合があります。ただし、戻る前に、 foo オブジェクトを削除する必要があります。 [1] でマークされた行に例示されているように、このようにコーディングすると忘れがちです。これにより、メモリ リークが発生します。繰り返しになりますが、この実装は単純化できますが、今回はスマート ポインター std::unique_ptr を使用します。 .

void example()
{
   std::unique_ptr<foo> ptr = std::make_unique<foo>();
   
   if(...)
   {
      return;
   }
   
   if(...)
   {
      return;
   }
   
   give_up_ownership(ptr.release());
}

new への明示的な呼び出しはありません (std::make_unique() に置き換え ) と delete ここ。さらに、give_up_ownership() 変わらないままです。 std::unique_ptr::release への呼び出し unique_ptr を切り離します オブジェクトを基になる生のポインターから取得し、生のポインターを返すため、スマート ポインターがスコープ外に出たときにオブジェクトを削除しようとしません。 std::vector を使用した前の例と同様に 新しい実装はよりシンプルで堅牢です。

C のような配列を避ける

C ライクな配列は、std::vector などの標準コンテナに置き換えることができます または std::array .私が何度も遭遇したパターンを次のスニペットに示します:

struct Object
{
   int ID;
   int Parent;
};

static Object Alist [] = 
{
    {1, 0},
    {2, 0},
    {3, 1},
    {4, 3},
};

#define NUM_OBJECTS (sizeof(list) / sizeof(Object))

for(int i = 0; i < NUM_OBJECTS; ++i)
{
   // do something with Alist[i]
}

Objects の配列があります そしてマクロ NUM_OBJECTS ハードコーディングされた値を避けるために、配列内の要素数を表すために使用されます (特に、配列内の要素数が実際に変更された場合にエラーが発生しやすくなります)。 std::vector または std::array ここでは常により良い代替手段です:

static std::vector<Object> Alist 
{
    {1, 0},
    {2, 0},
    {3, 1},
    {4, 3},
};

static std::array<Object, 4> Alist 
{
    {1, 0},
    {2, 0},
    {3, 1},
    {4, 3},
};

for(size_t i = 0; i < AList.size(); ++i)
{
   // do something with Alist[i]
}

メソッド size() コンテナ内の要素数を取得するために使用できますが、範囲ベースの for ループも使用できます。

for(auto const & element : AList)
{
   // do something with element
}

ポインター (最初の要素への) とサイズ (要素の数を指定するため) の形式で配列を入力として受け取る関数がある場合、配列または標準の引数で呼び出す場合でも、そのままにしておくことができます。コンテナー (std::array を含む) )。次の例を考えてみましょう:

void foo(int* arr, unsigned const size)
{
    if(arr != nullptr && size > 0)
    {
        for(unsigned i = 0; i < size; ++i)
            std::cout << arr[i] << '\n';
    }
}

これは次のように呼び出すことができます:

int main()
{
    int arr[3] = {1, 2, 3};
    foo(arr, sizeof(arr)/sizeof(int));
}

ただし、このコードを次のように変更しても、結果は同じになります:

int main()
{
    std::vector<int> vec {1, 2, 3};
    foo(vec.data(), vec.size());
}

適切なキャスト

(type)value の形式の C スタイルのキャスト式 C++ 開発者によって広く使用されていますが、そうすべきではありません。 C++ には、次の 4 つのキャスト演算子が用意されています。

  • static_cast :暗黙的およびユーザー定義の変換を使用して型を変換します (例には、列挙型から整数型への変換、浮動小数点型から整数型への変換、ポインター型から void へのポインターへの変換、基本クラスへのポインターから派生クラスへのポインターへの変換などがあります)。
  • reinterpret_cast :基礎となるビット パターンを再解釈することにより、型間の変換を行います (ポインター型と整数型の間の変換など)
  • dynamic_cast :クラスへのポインタまたは参照の間で、継承階層に沿って上下左右に安全な変換を実行します
  • const_cast :異なる cv-qualification を持つ型の間で変換します

ただし、明示的な C のようなキャストは次のように解釈されます (選択されているそれぞれのキャスト演算子を満たす最初の選択肢):

<オール>
  • const_cast
  • static_cast
  • static_cast 続いて const_cast
  • reinterpret_cast
  • reinterpret_cast 続いて const_cast
  • コードを次のように書く代わりに:

    int margin = (int)((cy - GetHeight())/2);
    MyEnum e = (MyEnum)value;
    foo* f = (foo*)lParam;

    次のように書く習慣を身につけてください:

    int margin = static_cast<int>((cy - GetHeight())/2);
    MyEnum e = static_cast<MyEnum>(value);
    foo* f = reinterpret_cast<foo*>(lParam);

    これにより、ユーザーの意図がより適切に表現され、コンパイラが不適切なキャストの試みにフラグを立てるのに役立ちます。 C++ キャストは、単純なテキスト検索で簡単に見つけることができます。これは便利な場合もあります。