0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コーディング規約とは

コーディング規約とは、プログラムを書くときの共通ルールです。

たとえば、次のようなことを決めます。

  • 変数や関数にどのような名前を付けるか
  • インデントや波括弧をどのように書くか
  • エラーが起きたときにどう扱うか
  • メモリを誰が確保して、誰が解放するか
  • コメントに何を書くか
  • 危険な書き方をどこまで禁止するか

C言語では、同じ動きをするコードでも、書き方によって安全性や読みやすさが大きく変わります。

たとえば、次の2つはどちらも文字列をコピーする処理です。

// NG: コピー先の大きさを確認できない
strcpy(destination, source);
// OK: コピー先の大きさを指定して確認できる
snprintf(destination, destination_size, "%s", source);

1人で短いプログラムを書く場合は、多少書き方が違っていても困らないかもしれません。

しかし、仕事では複数人で同じソースコードを修正します。
数か月後や数年後に、自分以外の人が修正することもあります。

そのため、書き方をそろえておかないと、次のような問題が起きます。

  • 名前から処理内容が分からない
  • どこでメモリを解放すればよいか分からない
  • エラーが起きても原因を追えない
  • 修正した人ごとに書き方が変わる
  • レビューで毎回同じ指摘が出る
  • 一見動いていても、特定の条件でだけ壊れる

コーディング規約は、コードを書く人を縛るためだけのものではありません。

自分やチームメンバーが、あとから安全に読み、修正し、テストできるようにするための共通ルールです。

C言語で特に規約が大切な理由

C言語は、組み込み機器、家電、車載機器、通信機器、OS、ゲーム、制御機器など、幅広い場所で使われている言語です。

一方で、C言語ではプログラム側で細かく管理する必要がある場面があります。

たとえば、次のような問題です。

  • 配列の範囲を超えて書き込む
  • 文字列の終わりを正しく扱わない
  • 確保したメモリを解放し忘れる
  • 解放したメモリをもう一度使う
  • エラーが起きたのに処理を続ける
  • 数値の範囲を超える
  • 環境によって動いたり動かなかったりする書き方をする

このような問題は、コンパイル時に必ずエラーになるとは限りません。

普段は動いていても、入力値、実行する順番、コンパイラの設定、OS、CPUが変わったときに不具合になることがあります。

そのためC言語では、「今動いているか」だけではなく、「安全に動き続ける書き方になっているか」を意識する必要があります。

この規約の位置づけ

この記事では、C言語でコードを書くときに守りたい一般的なコーディング規約をまとめます。

ただし、本記事はあくまで一般的な基準です。

顧客規約、製品規約、プロジェクト規約、設計書に決まりがある場合は、必ずそちらを優先してください。

ルールが複数ある場合は、次の順で優先します。

  1. 法令、認証規格、安全規格、セキュリティ要件
  2. 顧客規約、製品規約、契約上の取り決め
  3. プロジェクト規約、設計書、レビュー方針
  4. 本記事の一般規約
  5. 個人の好み

たとえば、本記事では関数名を小文字スネークケースで書く例を使います。

user_repository_find();

ただし、プロジェクトでPascalCaseを使うと決まっている場合は、そちらに合わせます。

UserRepositoryFind();

大切なのは、どちらが正しいかではありません。

同じプロジェクトの中で書き方が混ざらず、チーム全員が迷わず読めることです。

前提

本記事のコード例は、C99以降を想定しています。

boolstdint.hsnprintf// コメント、for 文内での変数宣言などは、C90/C89や古い組み込み向けコンパイラでは使えないことがあります。

実際に使用できる書き方は、顧客・製品・プロジェクトで指定されたC言語規格とコンパイラに従ってください。

分からない言葉は調べる

この記事には、最初は意味が分かりにくい言葉も出てきます。

  • ポインタ
  • メモリ
  • バッファ
  • NULL
  • ヌル文字
  • 未定義動作
  • 所有権
  • static
  • スレッド
  • 排他制御

分からない言葉が出てきた場合は、そのまま読み飛ばさず、検索や公式ドキュメントで意味を確認してください。

最初からすべて覚える必要はありません。

ただし、「何となく使う」のではなく、「何のために必要なのか」を少しずつ理解しながら進めることが大切です。

C言語では、言葉の意味を曖昧にしたまま使うと、配列外アクセス、メモリ破壊、解放漏れなどの不具合につながることがあります。

なお、NULL'\0' は似ていますが、別のものです。

  • NULL は、ポインタに有効な参照先がないことを表す値です。
  • '\0' は、C言語の文字列の終わりを表すヌル文字です。

この規約で目指すこと

この規約では、次の状態を目指します。

  • 名前を見ただけで役割が分かる
  • .c.h の役割が分かれている
  • メモリを誰が確保して、誰が解放するか分かる
  • 配列や文字列の範囲を安全に扱える
  • エラーが起きたときに原因を追いやすい
  • レビューで同じ指摘を繰り返さない
  • 将来修正する人が困らない

まず確認すること

レビュー前やコミット前に、最低限ここを確認します。

  • ファイル名とモジュール名が合っているか
  • 公開しない関数に static が付いているか
  • .h に内部実装を書いていないか
  • 変数名から意味が分かるか
  • malloccallocrealloc の結果を確認しているか
  • 確保したメモリを必ず解放しているか
  • 配列の範囲外にアクセスしていないか
  • 文字列の終端のヌル文字を意識しているか
  • strcpystrcatsprintf を使っていないか
  • 戻り値やエラーコードを無視していないか
  • コンパイラ警告が残っていないか
  • 分からない用語や処理を放置していないか

1. 命名規則

1-1. ファイル名

ファイル名は、小文字スネークケースを基本とします。

user_repository.c
user_repository.h
packet_parser.c
packet_parser.h
configuration_loader.c
configuration_loader.h

スネークケースとは、単語を _ でつなぐ書き方です。

user_repository
packet_parser
file_reader

ファイル名から、何を担当するモジュールなのか分かる名前にします。

user_repository
file_reader
network_client
packet_parser

次のような名前は、役割が分かりにくいため避けます。

common
utility
misc
other
data
work

common.c に便利な処理を追加し続けると、何でも入る箱になりやすくなります。

迷った場合は、「このファイルは何を担当しているか」を一言で説明できる名前にします。

1-2. 関数名

関数名は、小文字スネークケースで、基本的に「動詞 + 対象」の形にします。

user_result_t user_repository_find(
    user_repository_t *repository,
    int user_id,
    user_t *out_user);

bool packet_parser_is_valid(
    const uint8_t *data,
    size_t data_size);

void configuration_destroy(
    configuration_t *configuration);

関数名を見ただけで、次のことが分かる状態を目指します。

  • 何をする関数か
  • 何を対象にするか
  • 成功・失敗があるか
  • 作成、取得、更新、削除、破棄のどれか

次のような名前は避けます。

// NG: 何をする関数か分かりにくい
void process(void);
void execute(void);
void handle(void);
void work(void);

処理内容が具体的に分かる名前にします。

// OK
bool packet_parse_header(
    const uint8_t *data,
    size_t data_size,
    packet_header_t *out_header);

file_result_t file_read_all(
    const char *file_path,
    uint8_t **out_data,
    size_t *out_data_size);

1-3. 変数名

変数名も小文字スネークケースを基本とします。

size_t data_size;
int retry_count;
bool is_connected;
char file_path[256];

変数名から、何が入っているか分かるようにします。

// NG: 意味が分かりにくい
int n;
int work;
int ret;
char buf[256];
char data[256];
// OK
int retry_count;
file_result_t result;
char user_name[USER_NAME_MAX_LENGTH + 1U];
uint8_t packet_data[PACKET_MAX_SIZE];

短い名前は、用途が明確でスコープが狭い場合だけ使います。

for (size_t index = 0U; index < item_count; ++index)
{
    total += values[index];
}

この場合の index は、ループの添字だとすぐ分かるため問題ありません。

1-4. 真偽値

真偽値には、true のときに意味が通る名前を付けます。

bool is_valid;
bool is_connected;
bool has_error;
bool can_retry;
bool should_close;

次のような名前は避けます。

bool flg;
bool flag;
bool status;
bool check;

たとえば、次のコードは意味が読みやすくなります。

if (is_connected)
{
    send_packet(packet_data, packet_size);
}

1-5. 定数とマクロ

定数マクロは大文字スネークケースを使います。

#define USER_NAME_MAX_LENGTH (64U)
#define PACKET_HEADER_SIZE (8U)
#define NETWORK_DEFAULT_PORT (443U)

数値だけを見るより、意味のある名前を付けた方が読みやすくなります。

// NG
char user_name[65];
// OK
char user_name[USER_NAME_MAX_LENGTH + 1U];

+ 1U は、文字列の終端のヌル文字を入れるための領域です。

1-6. 型名

構造体や列挙型には、役割が分かる名前を付けます。

構造体は、実装詳細を .c ファイルに隠したい場合、.h ファイルでは前方宣言できます。

typedef struct user user_t;
typedef struct user_repository user_repository_t;

型名の付け方は、プロジェクトによって違います。

typedef struct User User;
typedef struct UserRepository UserRepository;

どちらを使う場合でも、プロジェクト内で統一します。

列挙型は、次の節のように定義と同時に型名を付けます。

1-7. 列挙型

列挙型の値には、モジュール名や種類が分かる接頭辞を付けます。

typedef enum
{
    USER_RESULT_OK = 0,
    USER_RESULT_INVALID_ARGUMENT,
    USER_RESULT_NOT_FOUND,
    USER_RESULT_NO_MEMORY,
    USER_RESULT_IO_ERROR
} user_result_t;

次のような短すぎる名前は、別のモジュールと名前がぶつかりやすくなります。

// NG
OK
ERROR
NONE
FAILED

2. ファイルとヘッダファイル

2-1. .c.h の役割

C言語では、主に次の2種類のファイルを使います。

.c : 実装を書くファイル
.h : 外部に公開する宣言を書くファイル

たとえば、ユーザー情報を扱うモジュールなら次のように分けます。

user_repository.c
user_repository.h

user_repository.h には、他のファイルから使ってよい関数や型だけを書きます。

#ifndef PROJECT_USER_REPOSITORY_H
#define PROJECT_USER_REPOSITORY_H

#include <stdbool.h>

typedef struct user_repository user_repository_t;

bool user_repository_open(
    const char *file_path,
    user_repository_t **out_repository);

void user_repository_close(
    user_repository_t *repository);

#endif

user_repository.c には、実際の処理や内部専用の関数を書きます。

#include "user_repository.h"

#include <stdlib.h>

struct user_repository
{
    char *file_path;
};

static bool is_valid_file_path(const char *file_path)
{
    return (file_path != NULL) && (file_path[0] != '\0');
}

2-2. ヘッダガードを付ける

ヘッダファイルには、必ずヘッダガードを付けます。

#ifndef PROJECT_PACKET_PARSER_H
#define PROJECT_PACKET_PARSER_H

/* 宣言を書く */

#endif

ヘッダガードは、同じヘッダファイルが複数回読み込まれたときのエラーを防ぐためのものです。

2-3. ヘッダは単体で使えるようにする

ヘッダファイルは、ほかのヘッダに依存しすぎないようにします。

たとえば、boolsize_tuint8_t を使うなら、必要な標準ヘッダを読み込みます。

#ifndef PROJECT_PACKET_H
#define PROJECT_PACKET_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

bool packet_is_valid(
    const uint8_t *data,
    size_t data_size);

#endif

「別のヘッダが先に読み込まれているから動く」という状態は避けます。

2-4. 自分のヘッダを最初に読み込む

.c ファイルでは、自分自身のヘッダを最初に読み込みます。

#include "packet_parser.h"

#include <stddef.h>
#include <stdint.h>
#include <string.h>

#include "packet_header.h"

こうすると、ヘッダファイルに必要なincludeが足りない場合に早く気付けます。

2-5. 外部に公開しない関数には static を付ける

ほかの .c ファイルから呼ばない関数には static を付けます。

static bool is_valid_header(
    const packet_header_t *header)
{
    return header->magic_number == PACKET_MAGIC_NUMBER;
}

static を付けると、その関数は同じ .c ファイルの中でしか使えなくなります。

意図しない場所から呼ばれることや、同じ名前の関数がぶつかることを減らせます。

2-6. グローバル変数はできるだけ作らない

グローバル変数は、どこからでも変更できるため、原因追跡が難しくなります。

// NG: どこからでも変更できる
int g_retry_count;

状態を持つ必要がある場合は、構造体にまとめて引数で渡す方法を優先します。

typedef struct
{
    int retry_count;
} retry_context_t;

どうしてもファイル内で共有する必要がある場合は、static を付けて公開範囲を狭くします。

static int s_connection_count;

ただし、可変の static 変数も状態を持つため、使いすぎないようにします。


3. コメント

3-1. コメントには「なぜ」を書く

コードを読めば分かることは、コメントにしません。

// NG: コードをそのまま説明している
// index を1増やす
index++;

なぜその処理が必要なのかを書きます。

// OK: 先頭4バイトはヘッダ情報のため、本文の開始位置まで進める。
index += PACKET_HEADER_SIZE;

コメントに書くとよいものは次の通りです。

  • 仕様上の理由
  • 制約
  • 例外理由
  • 外部システムとの取り決め
  • 一見すると不自然に見える処理の理由
  • 将来変更するときの注意点

3-2. 公開関数には説明を書く

ほかのモジュールから呼ばれる関数には、少なくとも次を書きます。

  • 何をする関数か
  • 引数の条件
  • 戻り値
  • 誰が解放するか
  • 失敗時にどうなるか
/**
 * 設定ファイルを読み込む。
 *
 * @param file_path 読み込む設定ファイルのパス。NULL不可。
 * @param out_configuration 読み込み結果の出力先。NULL不可。
 * @return 成功時は CONFIGURATION_RESULT_OK。
 *
 * 成功時、呼び出し側は *out_configuration に格納された設定情報を
 * configuration_destroy で解放する。
 */
configuration_result_t configuration_load(
    const char *file_path,
    configuration_t **out_configuration);

3-3. TODOには理由を書く

TODOを書く場合は、何を、なぜ後回しにしているかを残します。

// TODO(PROJ-1234): 通信断時の再接続仕様が未確定のため、再試行回数は暫定値を使用している。

次のようなTODOは、あとから見ても判断できません。

// TODO
// 後で直す
// 仮

3-4. コメントアウトしたコードは残さない

不要になったコードは削除します。

// old_process();
new_process();

過去のコードはGitで確認できます。
コメントアウトしたコードを残すと、今も必要なのか、不要なのか判断しにくくなります。


4. 書式と実装

4-1. インデントはスペース4個

インデントはスペース4個を基本とします。

if (is_valid)
{
    process_packet(packet_data, packet_size);
}

タブ文字とスペースが混ざると、環境によって見た目が崩れることがあります。

エディタ設定や.editorconfigなどで統一します。

4-2. 波括弧を省略しない

処理が1行でも、ifforwhile には波括弧を付けます。

// NG
if (is_valid)
    process_packet(packet_data, packet_size);
// OK
if (is_valid)
{
    process_packet(packet_data, packet_size);
}

あとから処理を追加したときの事故を防ぎやすくなります。

4-3. 1行に複数の処理を書かない

// NG
index++; count++;
// OK
index++;
count++;

1行ずつ分けた方が、デバッグ、レビュー、差分確認をしやすくなります。

4-4. 1つの宣言で複数の変数を宣言しない

// NG
int *left, right;

この場合、left はポインタですが、right は普通の int です。

// OK
int *left;
int right;

見間違いを防ぐため、1行に1変数だけ書きます。

4-5. 長い関数は分割する

関数が長くなりすぎた場合は、役割ごとに分割します。

分割を検討する目安は次の通りです。

  • 引数が多い
  • 変数が多い
  • ネストが深い
  • 途中から別の処理をしている
  • エラー処理が散らばっている
  • テスト観点を整理しにくい

ただし、細かく分けすぎて処理の流れが追えなくなる場合もあります。

「名前を付けられる意味のまとまり」で分けるのが基本です。


5. 戻り値とエラー処理

5-1. 戻り値の役割を決める

関数の戻り値は、何を返すものかを明確にします。

用途 使うもの
成功・失敗だけ bool
失敗理由も必要 列挙型
数値を返す 数値型
複数の値を返す 構造体または出力引数
作成したオブジェクトを返す ポインタ + 結果コード

たとえば、成功・失敗の理由が必要な場合は、bool より列挙型を使います。

typedef enum
{
    USER_RESULT_OK = 0,
    USER_RESULT_INVALID_ARGUMENT,
    USER_RESULT_NOT_FOUND,
    USER_RESULT_NO_MEMORY,
    USER_RESULT_IO_ERROR
} user_result_t;
user_result_t user_repository_find(
    user_repository_t *repository,
    int user_id,
    user_t *out_user);

5-2. エラーコードは明示的に比較する

結果コードは、明示的に比較します。

user_result_t result = user_repository_find(
    repository,
    user_id,
    &user);

if (result != USER_RESULT_OK)
{
    return result;
}

次のような書き方は、成功なのか失敗なのか分かりにくくなります。

// NG
if (user_repository_find(repository, user_id, &user))
{
    /* 成功か失敗か分かりにくい */
}

5-3. 引数チェックは関数の先頭で行う

NULL不可、サイズ制約、値の範囲は、関数の先頭で確認します。

packet_result_t packet_parse(
    const uint8_t *data,
    size_t data_size,
    packet_t *out_packet)
{
    if ((data == NULL) || (out_packet == NULL))
    {
        return PACKET_RESULT_INVALID_ARGUMENT;
    }

    if (data_size < PACKET_HEADER_SIZE)
    {
        return PACKET_RESULT_INVALID_FORMAT;
    }

    /* 本処理 */

    return PACKET_RESULT_OK;
}

外部入力、通信データ、設定ファイル、ユーザー入力は、常に不正な値が入る可能性があります。

5-4. 出力引数は成功時だけ更新する

出力引数は、成功したときだけ更新するようにします。

user_result_t user_parse(
    const char *text,
    user_t *out_user)
{
    user_t user = {0};

    if ((text == NULL) || (out_user == NULL))
    {
        return USER_RESULT_INVALID_ARGUMENT;
    }

    /* user に値を設定する処理 */

    *out_user = user;

    return USER_RESULT_OK;
}

途中で失敗したときに、out_user が中途半端な状態になることを防げます。

5-5. assert は外部入力の確認に使わない

assert は、開発中にプログラム内部の矛盾を見つけるためのものです。

assert(buffer != NULL);

ただし、リリースビルドでは無効になる場合があります。

通信データやファイル内容など、外部から入る値の確認を assert だけに任せてはいけません。

// NG
assert(data_size >= PACKET_HEADER_SIZE);
// OK
if (data_size < PACKET_HEADER_SIZE)
{
    return PACKET_RESULT_INVALID_FORMAT;
}

5-6. エラーを握り潰さない

失敗を検知したら、次のいずれかを明確にします。

  • 呼び出し元へ返す
  • ログに残す
  • 再試行する
  • 復旧する
  • 安全に終了する
configuration_result_t result = configuration_load(
    file_path,
    &configuration);

if (result != CONFIGURATION_RESULT_OK)
{
    return result;
}

失敗したのに成功扱いにすることは避けます。

// NG
if (result != CONFIGURATION_RESULT_OK)
{
    return CONFIGURATION_RESULT_OK;
}

5-7. ログは必要な場所で残す

同じエラーを複数の関数で何度も出すと、ログが読みにくくなります。

基本的には、処理の境界になる場所で、必要な情報を付けてログを残します。

result = configuration_load(file_path, &configuration);

if (result != CONFIGURATION_RESULT_OK)
{
    log_error(
        "設定ファイルの読み込みに失敗しました。path=%s result=%d",
        file_path,
        result);

    return result;
}

パスワード、認証トークン、個人情報、通信本文などはログに残しません。


6. メモリとリソース管理

6-1. 所有権を明確にする

C言語では、メモリを誰が確保して、誰が解放するかを明確にすることが重要です。

これを「所有権」と考えると分かりやすくなります。

たとえば、作成関数と破棄関数をセットにします。

user_result_t user_repository_create(
    const char *file_path,
    user_repository_t **out_repository);

void user_repository_destroy(
    user_repository_t *repository);

この場合は、次のルールにできます。

user_repository_create で確保する
user_repository_destroy で解放する

公開関数のコメントにも、解放する人を残します。

6-2. malloc の戻り値を確認する

malloc はメモリ確保に失敗すると NULL を返します。

int *values = malloc(count * sizeof *values);

if (values == NULL)
{
    return MEMORY_RESULT_NO_MEMORY;
}

Cでは、malloc の戻り値をキャストしません。

// NG
int *values = (int *)malloc(count * sizeof(int));
// OK
int *values = malloc(count * sizeof *values);

sizeof *values と書くと、型を変更したときにもサイズ指定の修正漏れが起きにくくなります。

6-3. 確保サイズのオーバーフローを確認する

次のような確保処理では、掛け算が大きくなりすぎる可能性があります。

malloc(count * sizeof *values);

count が非常に大きい場合、計算結果が正しくない小さな値になることがあります。

そのため、確保前に確認します。

int *values;

if (count > (SIZE_MAX / sizeof *values))
{
    return MEMORY_RESULT_SIZE_OVERFLOW;
}

values = malloc(count * sizeof *values);

if (values == NULL)
{
    return MEMORY_RESULT_NO_MEMORY;
}

6-4. realloc を直接代入しない

realloc はメモリ領域のサイズを変更する関数です。

ただし、失敗すると NULL を返し、元のポインタはそのまま有効です。

// NG
buffer = realloc(buffer, new_size);

if (buffer == NULL)
{
    return MEMORY_RESULT_NO_MEMORY;
}

この書き方では、失敗したときに元のポインタを失う可能性があります。

// OK
uint8_t *new_buffer = realloc(buffer, new_size);

if (new_buffer == NULL)
{
    return MEMORY_RESULT_NO_MEMORY;
}

buffer = new_buffer;

6-5. 確保したものは必ず解放する

解放が必要なのはメモリだけではありません。

  • メモリ
  • ファイル
  • ソケット
  • データベース接続
  • mutex
  • semaphore
  • デバイスハンドル
  • 一時ファイル
  • ロック状態

取得したものは、必ず決められた方法で解放します。

6-6. 後始末は1か所に集める

C言語では、エラー時の後始末が複雑になることがあります。

その場合は、後始末を1か所に集めます。

file_result_t file_read_all(
    const char *file_path,
    uint8_t **out_data,
    size_t *out_data_size)
{
    FILE *file = NULL;
    uint8_t *data = NULL;
    file_result_t result = FILE_RESULT_IO_ERROR;

    if ((file_path == NULL) ||
        (out_data == NULL) ||
        (out_data_size == NULL))
    {
        return FILE_RESULT_INVALID_ARGUMENT;
    }

    file = fopen(file_path, "rb");

    if (file == NULL)
    {
        result = FILE_RESULT_OPEN_FAILED;
        goto cleanup;
    }

    data = malloc(FILE_BUFFER_SIZE);

    if (data == NULL)
    {
        result = FILE_RESULT_NO_MEMORY;
        goto cleanup;
    }

    /* 読み込み処理 */

    *out_data = data;
    *out_data_size = FILE_BUFFER_SIZE;

    data = NULL;
    result = FILE_RESULT_OK;

cleanup:
    free(data);

    if (file != NULL)
    {
        fclose(file);
    }

    return result;
}

C言語では、後始末のために goto cleanup を使うことがあります。

これは、処理を飛び回るためではなく、解放漏れを防ぐための使い方です。

6-7. free 後のNULL代入だけでは安心しない

次のように、解放後にNULLを代入することは有効です。

free(buffer);
buffer = NULL;

ただし、別の変数が同じメモリを指している場合は防げません。

uint8_t *alias = buffer;

free(buffer);
buffer = NULL;

/* alias は解放済み領域を指したまま */

根本的には、同じメモリを誰が管理しているかを明確にします。


7. 数値、配列、文字列

7-1. 型は用途で選ぶ

型は、単に「小さそうだから」ではなく、用途で選びます。

用途 基本型
配列の要素数、バイト数、添字 size_t
ポインタ同士の差 ptrdiff_t
バイナリデータ uint8_t
通信プロトコルの固定幅整数 uint8_tuint16_tuint32_tuint64_t
成功・失敗 bool
一般的な整数計算 int
小数計算 floatdouble

通信データやファイル形式では、intlong のサイズに依存しないようにします。

7-2. 配列の要素数には size_t を使う

配列の添字や要素数には size_t を使います。

for (size_t index = 0U; index < item_count; ++index)
{
    process_item(items[index]);
}

size_t は、配列サイズやメモリサイズを表すための型です。

7-3. char の符号付き・符号なしを前提にしない

char は環境によって符号付きの場合と符号なしの場合があります。

文字列には char を使います。

const char *user_name;

バイナリデータには uint8_t を使います。

const uint8_t *packet_data;

文字列とバイナリデータを混同しないようにします。

7-4. 縮小変換をするときは範囲を確認する

大きい型から小さい型へ変換するときは、値が入る範囲か確認します。

uint32_t source_value = 1000U;

if (source_value > UINT16_MAX)
{
    return VALUE_RESULT_OUT_OF_RANGE;
}

uint16_t destination_value = (uint16_t)source_value;

キャストは安全にするためのものではありません。
安全だと確認した後で、「意図して変換している」と示すために使います。

7-5. 整数のオーバーフローに注意する

整数の計算結果が型の範囲を超えることを、オーバーフローといいます。

たとえば、次のような加算では範囲を超える可能性があります。

int total = left + right;

必要に応じて、計算前に範囲を確認します。

bool int_add(
    int left,
    int right,
    int *out_value)
{
    if (out_value == NULL)
    {
        return false;
    }

    if (((right > 0) && (left > (INT_MAX - right))) ||
        ((right < 0) && (left < (INT_MIN - right))))
    {
        return false;
    }

    *out_value = left + right;

    return true;
}

7-6. 文字列は終端のヌル文字を意識する

C言語の文字列は、末尾に '\0' というヌル文字を置き、文字列の終わりを表します。

char user_name[USER_NAME_MAX_LENGTH + 1U];

+ 1U は、終端のヌル文字のための領域です。

次の3つを混同しないようにします。

用語 意味
バッファサイズ 用意した領域全体の大きさ
文字列長 終端のヌル文字を除いた文字数
コピー可能文字数 バッファサイズ - 1

7-7. 危険な文字列関数は使わない

次の関数は、バッファサイズを扱いにくいため、原則として使いません。

gets
strcpy
strcat
sprintf
strncpy

文字列整形には snprintf を使います。

int written = snprintf(
    destination,
    destination_size,
    "%s-%u",
    name,
    sequence_number);

if ((written < 0) || ((size_t)written >= destination_size))
{
    return STRING_RESULT_TRUNCATED;
}

snprintf も、呼び出しただけで安心ではありません。
戻り値を確認して、文字列が途中で切れていないかを確認します。

また、%uunsigned int 用です。
値の型に合う書式指定子を使ってください。

7-8. バッファを受け取る関数にはサイズも渡す

バッファに書き込む関数では、バッファサイズも引数で受け取ります。

string_result_t user_name_copy(
    char *destination,
    size_t destination_size,
    const char *source)
{
    int written;

    if ((destination == NULL) ||
        (source == NULL) ||
        (destination_size == 0U))
    {
        return STRING_RESULT_INVALID_ARGUMENT;
    }

    written = snprintf(
        destination,
        destination_size,
        "%s",
        source);

    if ((written < 0) || ((size_t)written >= destination_size))
    {
        return STRING_RESULT_TRUNCATED;
    }

    return STRING_RESULT_OK;
}

次のように、サイズを受け取らない関数は危険です。

// NG
void user_name_copy(
    char *destination,
    const char *source)
{
    strcpy(destination, source);
}

7-9. memcpymemmove を使い分ける

コピー元とコピー先が重ならない場合は memcpy を使います。

memcpy(destination, source, data_size);

コピー元とコピー先が重なる可能性がある場合は memmove を使います。

memmove(destination, source, data_size);

使う前に、次を確認します。

  • ポインタがNULLではないか
  • コピーサイズが領域内か
  • コピー元とコピー先が重ならないか
  • 文字列として扱うなら終端のヌル文字が必要か
  • バイト数と要素数を混同していないか

7-10. 構造体をそのまま保存形式にしない

構造体には、コンパイラやCPUによってメンバー間や末尾に余白が入る場合があります。

また、整数のバイト順や型のサイズも環境によって異なります。

// NG: 環境差が出る可能性がある
fwrite(&header, sizeof(header), 1U, file);

通信データや保存ファイルでは、フィールドごとに書き出します。

write_uint32_be(file, header.magic_number);
write_uint16_be(file, header.version);
write_uint32_be(file, header.payload_size);

こうすると、CPUやコンパイラが変わっても扱いやすくなります。


8. プリプロセッサとマクロ

8-1. 関数風マクロはできるだけ使わない

関数のように見えるマクロは、引数が複数回評価されることがあります。

// NG
#define MIN(left, right) ((left) < (right) ? (left) : (right))

int value = MIN(index++, limit);

この場合、index++ が複数回実行される可能性があります。

可能なら、static inline 関数を使います。

static inline int min_int(
    int left,
    int right)
{
    return (left < right) ? left : right;
}

8-2. マクロは括弧で囲む

マクロを使う必要がある場合は、引数と式全体を括弧で囲みます。

#define ARRAY_COUNT(array) (sizeof(array) / sizeof((array)[0]))

演算子の優先順位による想定外の動きを防ぎやすくなります。

8-3. 条件コンパイルを増やしすぎない

#ifdef#if を増やしすぎると、どのコードが実際に使われるのか追いにくくなります。

#ifdef PLATFORM_WINDOWS
    /* Windows用処理 */
#elif defined(PLATFORM_LINUX)
    /* Linux用処理 */
#endif

OSごとの差分は、できるだけ専用ファイルに分けます。

socket_windows.c
socket_linux.c
socket.h

9. 並行処理と割り込み

9-1. volatile を排他制御に使わない

volatile は、コンパイラが変数へのアクセスを勝手に省略しないようにするための指定です。

スレッド安全性や排他制御を保証するものではありません。

// NG
volatile int g_count;

void increment(void)
{
    g_count++;
}

g_count++ は、読み取り、加算、書き込みの複数処理です。

複数スレッドや割り込みから同時に実行されると、値が正しく更新されない可能性があります。

プロジェクトで決めた同期方法を使います。

mutex_lock(&g_count_mutex);
g_count++;
mutex_unlock(&g_count_mutex);

使うAPIは、OS、RTOS、対象環境、顧客規約に合わせます。

9-2. 可変の static 変数を増やしすぎない

static は、関数に付ける場合と変数に付ける場合で意味が異なります。

  • 関数に付ける場合は、外部の .c ファイルから呼べないようにします。
  • 変数に付ける場合は、値を保持し続けるために使います。

関数内やファイル内の可変 static 変数は、呼び出し順序や並行実行に影響されやすくなります。

static int retry_count;

状態を持つ必要がある場合は、構造体にまとめて渡す方が分かりやすいことがあります。

typedef struct
{
    int retry_count;
} retry_context_t;

9-3. 割り込み処理では重い処理をしない

割り込み処理やコールバックでは、通常の関数と同じように長い処理をしてはいけない場合があります。

避けることが多い処理は次の通りです。

  • 動的メモリ確保
  • ファイル入出力
  • 長時間処理
  • ロック取得
  • 重いログ出力
  • 再入不可のAPI呼び出し

実際に何が使えるかは、OS、RTOS、SDK、顧客規約を確認します。


10. 依存関係、ビルド、テスト

10-1. 依存関係は一方向にする

下位のモジュールが上位のモジュールを直接参照しないようにします。

画面・業務処理
    ↓
アプリケーション処理
    ↓
共通処理・ドメイン処理
    ↓
通信・ファイル・OS・デバイス

通信モジュールが画面処理を直接呼ぶようになると、テストや修正が難しくなります。

10-2. 外部ライブラリを広げすぎない

外部ライブラリの型や関数を、アプリ全体で直接使わないようにします。

外部ライブラリ
    ↓
ラッパーモジュール
    ↓
自分たちのAPI

外部ライブラリを1か所で包むと、ライブラリ更新や差し替えに対応しやすくなります。

10-3. コンパイラ警告を残さない

警告は、可能な限りゼロにします。

よくある警告は次の通りです。

  • 未使用変数
  • 暗黙変換
  • 符号付き・符号なし比較
  • 戻り値未確認
  • 初期化漏れ
  • 型不一致
  • 到達不能コード

警告を消すためだけのキャストや空処理は避けます。

// NG: 警告を消すだけのキャスト
uint16_t value = (uint16_t)source_value;

本当に安全か確認してから変換します。

10-4. 静的解析を使う

静的解析では、次のような問題を見つけやすくなります。

  • NULL参照
  • 配列外アクセス
  • 未初期化変数
  • 解放漏れ
  • 二重解放
  • 符号変換
  • 到達不能コード
  • 危険な文字列操作

ただし、静的解析が通っただけでは、仕様どおりに動く保証にはなりません。

設計、レビュー、テストも必要です。

10-5. テストでは失敗するケースも確認する

C言語では、正常系だけではなく、失敗するケースも確認します。

最低限、次のケースを考えます。

  • NULL
  • 空文字列
  • サイズ0
  • 最大値
  • 最小値
  • バッファ境界ちょうど
  • バッファ境界を1超えた値
  • メモリ確保失敗
  • ファイルを開けない場合
  • 通信切断
  • 不正なパケット
  • 繰り返し生成・破棄
  • 並行実行

11. レビュー観点

モジュールと命名

  • ファイル名から役割が分かるか
  • 関数名から何をするか分かるか
  • 変数名から何が入っているか分かるか
  • .h に内部実装が漏れていないか
  • 公開しない関数に static が付いているか
  • commonutility に処理を集めすぎていないか

エラー処理

  • 引数チェックをしているか
  • 戻り値を確認しているか
  • エラーコードの意味が分かるか
  • 失敗時に出力引数が壊れないか
  • エラーを成功扱いにしていないか
  • 必要なログが残るか
  • 機密情報をログに出していないか

メモリとリソース

  • 誰が確保して誰が解放するか分かるか
  • malloccallocrealloc の結果を確認しているか
  • 確保サイズの計算が安全か
  • realloc を直接代入していないか
  • すべての終了経路で解放されるか
  • 二重解放や解放後利用がないか
  • ファイル、ソケット、ロックも解放しているか

配列、文字列、数値

  • 配列サイズを超えていないか
  • バッファサイズを引数で渡しているか
  • 終端のヌル文字を保証しているか
  • strcpystrcatsprintf を使っていないか
  • snprintf の戻り値を確認しているか
  • 縮小変換前に範囲確認しているか
  • 符号付き・符号なし比較で意図しない変換がないか
  • 整数オーバーフローの可能性がないか

並行処理

  • volatile を排他制御に使っていないか
  • 可変global変数や可変static変数を増やしすぎていないか
  • 同時実行される可能性を考えているか
  • 割り込み処理で重い処理をしていないか

12. まとめ

C言語では、コードが動くことだけでなく、安全に読み、修正し、保守できることが重要です。

最初からすべてを覚える必要はありません。

まずは次を意識すると、コードの品質が上がりやすくなります。

  • 名前から役割が分かるようにする
  • .c.h の役割を分ける
  • 分からない用語を放置しない
  • メモリを誰が解放するか明確にする
  • 配列や文字列のサイズを確認する
  • 戻り値やエラーコードを無視しない
  • 危険な文字列関数を使わない
  • コンパイラ警告を残さない
  • 顧客規約やプロジェクト規約がある場合は必ずそちらを優先する

規約は、コードを書く人を困らせるためのものではありません。

将来、自分やチームメンバーが安全に修正できるようにするためのものです。

最初はレビュー時にこのページを見ながら確認し、少しずつ自然にできるようになることを目指します。

更新履歴

  • 2026-06-23: 初版
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?