【C++】C4996「This function or variable may be unsafe」とは?|_CRT_SECURE_NO_WARNINGSを書く位置の罠と、警告を消す前に知りたい実測結果

【C++】C4996「This function or variable may be unsafe」とは?|_CRT_SECURE_NO_WARNINGSを書く位置の罠と、警告を消す前に知りたい実測結果 C++

Visual Studioで、昔のサンプルコードを写して動かしたら、ビルド結果に黄色い警告がずらりと並びました。「fopenは安全でない可能性があります」。コードは、教科書どおりのはずなのに、です。

調べると、検索結果の大半は「_CRT_SECURE_NO_WARNINGSを書けば消える」でした。確かに消えます。ただ、書く位置を間違えると、書いたのに消えないこともあります。そもそも、この警告は消してしまって、本当によいのでしょうか。

そこで、警告の実物と、消し方、消したあとで何が変わるのかを、MSVC(19.44、x64)で実際に動かして確かめました。

C4996とは?

C4996とは、

・「この関数は、使わないほうがよいとされています」という、コンパイラからの通知。関数ごとに、理由の書かれた文面が付く

です。名前の「4996」は、廃止予定・非推奨の機能に使われる、警告番号です。C++の文法違反ではないので、ビルドは通ります。ただし、設定によっては、エラーとして扱われて、ビルドが止まります(後述)。

実験①:警告の実物を見る

fopen・strcpy・sprintfを使った、小さなプログラムを用意しました。

▼c1_warn.cpp
#include <cstdio>
#include <cstring>

int main()
{
    FILE* fp = fopen("test.txt", "w");   // 警告の対象①
    char buf[16];
    strcpy(buf, "hello");                // 警告の対象②
    sprintf(buf, "%d", 123);             // 警告の対象③
    if (fp)
    {
        fputs(buf, fp);
        fclose(fp);
    }
    std::printf("書き込み完了: %s\n", buf);
    return 0;
}

これを、Visual Studioの既定と同じ警告レベル(/W3)で、ビルドしました。

c1_warn.cpp
c1_warn.cpp(6): warning C4996: 'fopen': This function or variable may be unsafe. Consider using fopen_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
c1_warn.cpp(8): warning C4996: 'strcpy': This function or variable may be unsafe. Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
c1_warn.cpp(9): warning C4996: 'sprintf': This function or variable may be unsafe. Consider using sprintf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.

3つの関数それぞれに、警告が出ました。文面を読むと、「fopen_sのほうを使いなさい」「警告を消すなら、_CRT_SECURE_NO_WARNINGSを使いなさい」と、対処法がそのまま書いてあります。ビルドは成功しており、実行もできます。

書き込み完了: 123

実験②:エラーになる設定がある

同じコードを、/sdl(SDLチェック)を付けてビルドしました。

c1_warn.cpp
c1_warn.cpp(6): error C4996: 'fopen': This function or variable may be unsafe. Consider using fopen_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
c1_warn.cpp(8): error C4996: 'strcpy': This function or variable may be unsafe. Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
c1_warn.cpp(9): error C4996: 'sprintf': This function or variable may be unsafe. Consider using sprintf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.

警告(warning)だったものが、エラー(error)になり、ビルドが止まりました。Visual Studioのプロジェクトのプロパティには、「C/C++ → 全般 → SDLチェック」という項目があり、これが有効だと、同じ現象になります。新しく作ったプロジェクトでは、この項目が有効になっていることが多く、「前のプロジェクトでは警告だったのに、今回はエラーで止まる」の原因になります。

警告を消す方法と、【重要】書く位置の罠

警告を消す方法として、いちばん知られているのが、_CRT_SECURE_NO_WARNINGSを定義することです。まず、コードの先頭で定義した版を試しました。

▼c2_define_before.cpp(先頭で定義)
#define _CRT_SECURE_NO_WARNINGS   // 必ず、最初のincludeより前に書く
#include <cstdio>
#include <cstring>

int main()
{
    FILE* fp = fopen("test.txt", "w");
    char buf[16];
    strcpy(buf, "hello");
    sprintf(buf, "%d", 123);
    if (fp)
    {
        fputs(buf, fp);
        fclose(fp);
    }
    std::printf("書き込み完了: %s\n", buf);
    return 0;
}
c2_define_before.cpp

ファイル名が出ているだけで、警告は1つも出ませんでした。次に、#includeのあとに書いた版です。

▼c3_define_after.cpp(#includeのあとで定義)
#include <cstdio>
#include <cstring>
#define _CRT_SECURE_NO_WARNINGS   // includeより「後」に書いた(効かない)

int main()
{
    FILE* fp = fopen("test.txt", "w");
    char buf[16];
    strcpy(buf, "hello");
    sprintf(buf, "%d", 123);
    if (fp)
    {
        fputs(buf, fp);
        fclose(fp);
    }
    std::printf("書き込み完了: %s\n", buf);
    return 0;
}
c3_define_after.cpp
c3_define_after.cpp(7): warning C4996: 'fopen': This function or variable may be unsafe. Consider using fopen_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
c3_define_after.cpp(9): warning C4996: 'strcpy': This function or variable may be unsafe. Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.
c3_define_after.cpp(10): warning C4996: 'sprintf': This function or variable may be unsafe. Consider using sprintf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.

書いてあるのに、警告は、消えませんでした。#defineは、書かれた位置より後ろにしか効きません。警告の元になる宣言は、#include <cstdio>の中にあるため、それより前に定義しておく必要があるのです。

ヘッダーをたくさん読み込むプロジェクトでは、この順番を守り切るのが大変です。そこで、ビルドの設定で定義する方法のほうが、確実です。コマンドラインでは/Dで定義できます。

cl /W3 /D_CRT_SECURE_NO_WARNINGS c1_warn.cpp
c1_warn.cpp

同じコード(c1_warn.cpp)でも、警告は出ませんでした。Visual Studioでは、プロジェクトのプロパティの「C/C++ → プリプロセッサ → プリプロセッサの定義」に、_CRT_SECURE_NO_WARNINGSを足すのが、これと同じ設定です。

警告を消すだけで、問題ないのか?

消し方は分かりました。ただ、「安全でない可能性」という文面は、本当なのでしょうか。確かめるために、入りきらない文字列をコピーする実験をしました。まず、strcpyです。

▼c6_overflow_cpy.cpp(8バイトの箱に、44バイトをコピー)
#include <cstdio>
#include <cstring>

int main()
{
    char buf[8];
    std::printf("コピー前\n");
    std::fflush(stdout);
    strcpy(buf, "this string is much longer than eight bytes");   // 8バイトに入らない
    std::printf("コピー後: %s\n", buf);
    std::fflush(stdout);
    return 0;
}

Releaseビルドでは、コンパイルの時点で、別の警告(C4789)が出ました。

c6_overflow_cpy.cpp
C:\blogtest\c4996\c6_overflow_cpy.cpp(9) : warning C4789: 関数 'main' で バッファー 'buf' (サイズが 8 バイト) はオーバーランされます; 44 バイトがオフセット 0 から書き込まれます

実行すると、次のようになりました(DebugビルドもReleaseビルドも、同じ結果でした)。

終了コード: 0xC0000409
--- 標準出力 ---
コピー前
コピー後: this string is much longer than eight bytes

コピーは、実行されて、画面に表示までされました。8バイトの箱から44バイトがあふれて、その先のスタックを壊したのに、その場では何も止められていません。壊れたことに気づいたのは、プログラムが終わる段階で、終了コードは0xC0000409(スタックのバッファーオーバーラン)でした。

では、同じことをstrcpy_sでやると、どうなるでしょうか。

▼c7_overflow_cpy_s.cpp
#include <cstdio>
#include <cstring>

int main()
{
    char buf[8];
    std::printf("コピー前\n");
    std::fflush(stdout);
    strcpy_s(buf, sizeof(buf), "this string is much longer than eight bytes");   // 8バイトに入らない
    std::printf("コピー後: %s\n", buf);
    std::fflush(stdout);
    return 0;
}
終了コード: 0xC0000409
--- 標準出力 ---
コピー前

Releaseでは、「コピー後」の表示が出る前に、プログラムが止まりました。コピーをする前に、サイズが足りないことを検出して、止めたということです。Debugビルドで動かすと、原因を表示してくれました。

strcpy_sに小さすぎるバッファーを渡したとき、デバッグビルドで出たDebug Assertion Failedのダイアログ
デバッグビルドでは、`Buffer is too small`(バッファーが小さすぎる)と、はっきり教えてくれる
Debug Assertion Failed!
Program: C:\blogtest\c4996\ov_strcpys_dbg.exe
File: minkernel\crts\ucrt\inc\corecrt_internal_string_templates.h
Line: 81
Expression: (L"Buffer is too small" && 0)
For information on how your program can cause an assertion
failure, see the Visual C++ documentation on asserts.
(Press Retry to debug the application)

警告が言っていた「安全でない可能性」は、大げさではありませんでした。strcpyは、壊してから、あとで気づく。strcpy_sは、壊す前に止める。これが、実測で見えた違いです。警告を黙らせるだけだと、この違いを、手放してしまうことになります。

直し方:_s版の関数か、C++の標準機能に置き換える

①MSVCの_s版に置き換える

fopenはfopen_s、strcpyはstrcpy_s、sprintfはsprintf_sに変えます。書き込み先のサイズを引数で渡す形になり、fopen_sは戻り値で、成功か失敗かが分かります。

▼c4_safe.cpp
#include <cstdio>
#include <cstring>

int main()
{
    FILE* fp = nullptr;
    if (fopen_s(&fp, "test.txt", "w") != 0)   // 戻り値(エラー番号)で成否が分かる
    {
        std::printf("ファイルを開けませんでした\n");
        return 1;
    }
    char buf[16];
    strcpy_s(buf, sizeof(buf), "hello");      // 書き込み先のサイズを渡す
    sprintf_s(buf, sizeof(buf), "%d", 123);
    fputs(buf, fp);
    fclose(fp);
    std::printf("書き込み完了: %s\n", buf);
    return 0;
}
c4_safe.cpp
書き込み完了: 123

警告は出ず、実行もできました。ただし、_s版の関数は、C言語の標準規格の付録に載っている扱いで、MSVC以外のコンパイラでは、使えない環境が多いです。他のコンパイラでも動かしたいコードでは、注意が必要です。

②C++の標準機能に置き換える

C++で書いているなら、そもそもfopenやstrcpyを使わない選択もあります。ファイルはstd::ofstream、文字列はstd::stringを使います。

▼c5_cpp.cpp
#include <fstream>
#include <iostream>
#include <string>

int main()
{
    std::ofstream ofs("test.txt");
    if (!ofs)
    {
        std::cout << "ファイルを開けませんでした\n";
        return 1;
    }
    std::string s = "hello";
    s = std::to_string(123);
    ofs << s;
    std::cout << "書き込み完了: " << s << "\n";
    return 0;
}
c5_cpp.cpp
書き込み完了: 123

こちらは、警告が出ず、他のコンパイラでも同じコードが動きます。バッファーのサイズを自分で管理しなくてよい点も、安全です。

別のC4996:C++の非推奨機能でも出る

C4996は、CRTの関数だけでなく、C++の標準ライブラリで非推奨になった機能でも出ます。例として、文字コードの変換でよく使われるstd::wstring_convertを、C++の規格(/std:)を変えて、ビルドしました。

▼c8_codecvt.cpp
#include <codecvt>
#include <locale>
#include <string>

int main()
{
    std::wstring_convert<std::codecvt_utf8<wchar_t>> conv;   // C++17で非推奨
    std::string s = conv.to_bytes(L"hello");
    return (int)s.size();
}
ビルドの規格警告
/std:c++14出ない
/std:c++17出る(C4996 / STL4017の4行)
/std:c++20出る(同じ4行)
同じコードで、規格を変えただけ
c8_codecvt.cpp(7): warning C4996: 'std::codecvt_utf8<wchar_t,1114111,(std::codecvt_mode)0>': warning STL4017: std::wbuffer_convert, std::wstring_convert, and the <codecvt> header (containing std::codecvt_mode, std::codecvt_utf8, std::codecvt_utf16, and std::codecvt_utf8_utf16) are deprecated in C++17. (中略) consider using MultiByteToWideChar() and WideCharToMultiByte() from <Windows.h> instead. You can define _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING or _SILENCE_ALL_CXX17_DEPRECATION_WARNINGS to suppress this warning.
(同じ警告が、ほかに3行出た)

文面の最後に、「MultiByteToWideChar()とWideCharToMultiByte()を使いなさい」と、置き換え先が書かれています。_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNINGを定義すれば消せますが、「規格が非推奨にした機能」なので、この場合は、置き換えるほうが長持ちします。

C4996が出たときの、よくある失敗例と対処法

①#defineを、#includeのあとに書く

実験で見たとおり、効きません。先頭に書くか、ビルド設定の「プリプロセッサの定義」で指定します。

②「前は警告だったのに」と、プロジェクトを疑う

警告がエラーになるのは、/sdlなどの設定の違いです。コードではなく、プロジェクトのプロパティを、前のプロジェクトと見比べます。

③警告を消したあと、サイズの確認を忘れる

_CRT_SECURE_NO_WARNINGSは、警告を消すだけで、strcpyの危険を減らしてはくれません。実験のとおり、あふれても、その場では止まりません。コピー先のサイズは、自分で確認します。

④置き換えるときに、strcpy_sの第2引数をsizeof以外にする

strcpy_sは、書き込み先のサイズを渡す関数です。実際より大きな値を渡すと、検出の意味がなくなります。配列なら、sizeof(buf)を使います。

注意点

  • C4996は廃止予定・非推奨の通知。ビルドは通る。ただし/sdlが有効だとエラーになる
  • _CRT_SECURE_NO_WARNINGSは、#includeより前に書くか、ビルド設定の「プリプロセッサの定義」で指定する
  • 警告を消しても、strcpyの危険は、そのまま。今回の実験では、8バイトの箱に44バイトが書き込まれた
  • strcpy_sは、コピーの前に、サイズ不足を検出して止める(Debugでは原因を表示する)
  • _s版は、MSVC以外では使えないことが多い。C++なら、std::stringやstd::ofstreamに置き換える
  • wstring_convertのように、規格で非推奨になった機能にも、C4996は出る
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属)で行った。警告の文面は、版によって変わりうる

結局、どうすればいい?

  • 黄色い警告は「消す」より先に「読む」。文面に、置き換え先が書いてある
  • 消す場合は、/D_CRT_SECURE_NO_WARNINGSをビルド設定に入れる(位置の罠を避けられる)
  • 新しく書くコードは、_s版か、C++の標準機能を使う
  • 規格で非推奨になった機能は、置き換え先に移るのが長持ちする

警告は、消せば静かになりますが、そのぶん、気づく機会も減ります。「出たら、一度は文面を読む」。それだけでも、strcpyが静かにスタックを壊すようなバグは、ずいぶん減らせます。

関連記事