😺

C++ での C 文字列の比較

2022/04/19に公開

はじめに

C 文字列 (NTBS; null-terminated byte string) の等値比較について。
若手プログラマーが書いたコードのレビュー中に遭遇した問題がきっかけです。

前提

第三者が提供するライブラリの API にこのような関数があります:

keyvalue.h
#define MAX_VALUE_LENGTH xxx

void GetValue(const char *keyName, char *valueBuffer);

ある種の Key-Value ストアで、 Key を指定して Value を取得する関数です。
主な仕様は次のとおりです:

  • Key も Value も C 文字列 (NTBS) です。
  • Value の最大長は固定で MAX_VALUE_LENGTH マクロで提供されており、それゆえに GetValue 関数にはバッファサイズを受け取る引数はありません。
  • 引数 keyName で指定された Key が存在する場合は、それに対応する Value が引数 valueBuffer の先に書き込まれ、 null 終端されます。
  • 引数 keyName で指定された Key が存在しない場合は、引数 valueBuffer の先に null 終端文字のみが書き込まれます。

問題となったコード

さて、レビューに持ってきてくれたコードはこんな感じのものでした:

#include <string.h>
#include <keyvalue.h>

#define HOGE_KEY "xxx"
#define HOGE_VALUE_A "aaa"
#define HOGE_VALUE_B "bbb"

void hoge() {
  char buffer[MAX_VALUE_LENGTH] = "";
  // Key "xxx" に対応する Value を得る
  GetValue(HOGE_KEY, buffer);
  if (strncmp(buffer, HOGE_VALUE_A, strlen(HOGE_VALUE_A)) == 0) {
    // Value が "aaa" の場合の処理
  } else if (strncmp(buffer, HOGE_VALUE_B, strlen(HOGE_VALUE_B)) == 0) {
    // Value が "bbb" の場合の処理
  } else {
    // それ以外の場合の処理
  }
}

いくつか改善すべき点はありますね。 たとえば...

  • <string.h> ではなく <cstring> をインクルードし、 strncmpstrlen 関数は名前空間名 std:: で修飾しよう。
  • 文字列リテラルはマクロではなく定数で扱おう。
  • グローバル名前空間を汚さないようにしよう。

何が問題?

それよりももっと深刻な問題は strncmp 関数の第 3 引数です。
たとえば、 GetValue 関数に Key "xxx" を渡したときに "aaa2" が返ってきたらどうでしょうか?
コードを書いたプログラマーの期待値はもちろん「それ以外の場合の処理」です。
でも、最初の if 文の条件式 strncmp(buffer, HOGE_VALUE_A, strlen(HOGE_VALUE_A)) == 0buffer の中身が "aaa2" の場合にも真となります。
先頭から 3 文字分しか比較していないからです。
そのため、実際の Value は "aaa2" にも関わらず、「Value が "aaa" の場合の処理」が実行されてしまいます。

どうしたらいい?

対策を考えてみましょう。
"aaa""aaa2" を等しいと誤判定してしまったのは、文字列を途中までしか比較していなかったからでした。

代わりに strcmp 関数を使う

比較に strncmp 関数を使っているのは、比較対象のバッファが null 終端されていない場合にバッファオーバーリード (Wikipedia) を防ぐためです。
しかし、今回のケースでは、第 1 引数も第 2 引数も必ず null 終端されることが明らかです。
そのため、 strncmp 関数の代わりに strcmp 関数を使っても問題とはなりません。

  if (std::strcmp(buffer, HOGE_VALUE_A) == 0) {
    // Value が "aaa" の場合の処理

とはいえ、コードを見ただけでは、常に null 終端されているのか、されていない場合があるのかを区別できないこともあるでしょう。
null 終端される保証がない文字列の比較には strncmp 関数を使うべきですね。
そのため、 strcmp 関数を使うというのは実際には難しいかもしれません。

また、 strcmp のように長さを指定しない <cstring> の関数の使用が禁止されていることもあります。
たとえば、主に自動車業界で使われている (使われていた?) コーディングルール MISRA C++:2008 にはそのものずばりのルールがあります[1]
コーディングルールの運用についてはこの記事では触れません。

先に長さを比較する

引き続き strncmp 関数を使うのであれば、先に文字列の長さを比較すればよいですね。
長さが等しくない文字列同士は必ず異なるので。

  if ((std::strlen(buffer) == std::strlen(HOGE_VALUE_A)) &&
      (std::strncmp(buffer, HOGE_VALUE_A, std::strlen(HOGE_VALUE_A)) == 0)) {
    // Value が "aaa" の場合の処理

strncmp 関数の第 3 引数は strlen(buffer) でも strlen(HOGE_VALUE_A) でもどちらでもよいのですが、片方が文字列リテラルであればそちらを使う方がコンパイル時の最適化が効くので望ましいです。

std::string クラスを使う

ここまでは C 文字列 (NTBS) をそのまま扱う方法を見てきました。
でも、 C++ であれば素直に std::string クラスを使いましょう。

  char buffer[MAX_VALUE_LENGTH] = "";
  // Key "xxx" に対応する Value を得る
  GetValue(HOGE_KEY, buffer);
  if (std::string s{buffer}; s == HOGE_VALUE_A) {
    // Value が "aaa" の場合の処理

使い慣れた == 演算子で等値比較できます。
null 終端文字とかバッファオーバーリードとか、めんどうな上にセキュリティ上のリスクになり得るようなことは考えずにすみます。

std::string_view クラスを使う

比較のためだけに一時的に std::string オブジェクトを生成するのは (文字列の長さによっては動的なメモリ確保が行われるので) ちょっと... と気になる人もいるかもしれませんね。
std::string クラスの代わりに std::string_view クラスを使う方法もあります。
こちらは動的メモリ確保も文字列のコピーも行われないのでモア・ベターかなと思います。

  char buffer[MAX_VALUE_LENGTH] = "";
  // Key "xxx" に対応する Value を得る
  GetValue(HOGE_KEY, buffer);
  if (std::string_view sv{buffer}; sv == HOGE_VALUE_A) {
    // Value が "aaa" の場合の処理

ただし、「std::string_view って何?」というプログラマーが支配的なチーム/組織では控える方がよいかもしれません。 (std::string_view に限りませんが)

おわりに

<cstring> が提供する関数を一般のアプリケーションのコードで使うのはやめましょう。

脚注
  1. 今手元に同書がないので確認できていませんが、たぶん Rule 18-0-5 だと思います。 ↩︎

Discussion