C言語のきほん|関数の応用(マクロと再帰)

関数をもっと使いこなすために、マクロと再帰という一歩先の考え方を身につけよう

C言語で関数の基本を学ぶと、
「同じ処理をまとめて書ける」
「引数で値を受け取れる」
「return で結果を返せる」
といった便利さが見えてきます。

でも、C言語には、関数に似ていながら少し違う仕組みや、関数が自分自身を呼び出すような少し発展的な書き方もあります。
それが、関数形式マクロ再帰関数 です。

関数形式マクロは、見た目は関数のようですが、実際には前処理の段階で文字列を置き換える仕組みです。
うまく使うと短く書けて便利ですが、使い方を誤ると予想外の動きになることがあります。

一方、再帰関数は、関数の中で自分自身を呼び出す書き方です。
最初は少し不思議に見えるかもしれませんが、問題によってはとても自然で分かりやすい表現になります。

この2つはどちらも「関数の応用」としてよく学ばれる内容ですが、便利さの裏に注意点もあります。
ここでは、関数形式マクロと再帰関数について、基本の考え方、メリット、注意点を、やさしく丁寧に整理していきます。

関数形式マクロとは

関数形式マクロとは、引数を持ち、関数のように使えるマクロ のことです。
ただし、本当の関数ではありません。

書き方は次の形です。

#define マクロ名(引数) 引数を含む文字列

たとえば、2つの値のうち大きいほうを選ぶマクロなら、次のように書けます。

#define MAX(a, b) (((a) > (b)) ? (a) : (b))

この MAX(a, b) は、関数を呼び出しているのではなく、前処理の段階で文字列として展開されます。

たとえば、

MAX(x, y)

と書くと、実際には次のような形へ置き換えられるイメージです。

(((x) > (y)) ? (x) : (y))

つまり、関数形式マクロは、関数のように見えて、実体は文字列の置き換え です。

条件演算子 ?: を使っている

MAX のようなマクロでは、条件演算子 ?: がよく使われます。

((a) > (b)) ? (a) : (b)

これは、

  • a > b が真なら a
  • 偽なら b

を返すという意味です。

if 文を短く式の形で書いたものだと思うと分かりやすいです。

サンプルプログラム

ここではシンプルな例として、2つの数のうち小さいほうを返す関数形式マクロ を作成します。

ファイル名:13_22_1.c

// 関数形式マクロで小さい方の値を求めるプログラム
#include <stdio.h>

#define MIN(a, b) (((a) < (b)) ? (a) : (b))

int main(void)
{
    int x = 14;
    int y = 9;

    printf("小さい方: %d\n", MIN(x, y));

    return 0;
}

実行結果例

小さい方: 9

このプログラムでは、

MIN(x, y)

が前処理で

(((x) < (y)) ? (x) : (y))

のように展開され、その結果として 9 が選ばれます。

関数形式マクロの特徴

関数形式マクロには、関数とは違う特徴があります。
代表的なものを整理すると、次のようになります。

特徴内容
型に依存しにくい文字列の置き換えなので、型にしばられにくい
関数呼び出しの形で書ける見た目は関数に近い
前処理で展開される関数呼び出しではない
オーバーヘッドがない呼び出しの手間がない
注意点が多い括弧や副作用に気をつける必要がある

① 型に依存しにくい

関数形式マクロは単なる文字列の置き換えなので、引数の型を厳密に決めません。

たとえば、次のようにも使えます。

printf("%f\n", MAX(3.5, 2.1));

この場合でも展開されるだけなので、double 型どうしの比較として動きます。

つまり、マクロは int 専用、double 専用 といった関数より、見た目の上では柔軟に使えることがあります。

② 展開されるので関数呼び出しのオーバーヘッドがない

普通の関数を呼び出すときには、

  • 引数を渡す
  • 関数へ移動する
  • 処理して戻る
  • 返却値を受け取る

といった流れがあります。

これを関数呼び出しのオーバーヘッドと呼ぶことがあります。

一方、マクロは前処理でその場に展開されるので、関数呼び出しそのものがありません。
そのため、単純な場面では高速に見えることがあります。

ただし、現代のコンパイラは関数の最適化も強力なので、いつでも「マクロのほうが有利」と単純には言えません。
学習段階では、マクロは展開、関数は呼び出し という違いをまず押さえるのが大切です。

③ 括弧を厳重に付ける必要がある

関数形式マクロでは、括弧の付け方がとても重要 です。

たとえば、平方を求めるマクロを次のように書いたとします。

#define SQUARE(x) (x * x)

一見よさそうに見えますが、これには問題があります。

もし

SQUARE(a + 1)

と書くと、展開後は

(a + 1 * a + 1)

のようになってしまいます。

これは意図した

(a + 1) * (a + 1)

とは違います。

そこで、正しくは次のように厳重に括弧を付けます。

#define SQUARE(x) ((x) * (x))

このように、マクロでは 引数ごと、そして全体をしっかり括弧で囲む のが大切です。

括弧の重要性を確認するシンプルな例

ファイル名:13_22_2.c

// 括弧の重要性を確認するプログラム
#include <stdio.h>

#define SQUARE(x) ((x) * (x))

int main(void)
{
    int a = 3;

    printf("二乗の結果: %d\n", SQUARE(a + 1));

    return 0;
}

実行結果例

二乗の結果: 16

ここでは SQUARE(a + 1) が

((a + 1) * (a + 1))

の意味で展開されるので、正しく 16 になります。

④ 副作用に注意する

関数形式マクロで特に危険なのが、副作用のある式を引数に渡すことです。

たとえば次のようなコードを考えます。

int a = 5;
int b = MAX(a++, 10);

MAX が

#define MAX(a, b) (((a) > (b)) ? (a) : (b))

なら、展開後はだいたい次のような形になります。

(((a++) > (10)) ? (a++) : (10))

この場合、a++ が2回評価される可能性があります。
つまり、a が予想以上に増えてしまうかもしれません。

これは関数とは大きく違う点です。
関数なら引数は評価されてから渡されますが、マクロは文字列展開なので、その中で何度も使われている式は何度も評価されることがあります。

関数形式マクロと関数の違いを整理しよう

項目関数形式マクロ関数
実体前処理での文字列置換実行時の関数呼び出し
厳密な型宣言なし型を持つ
オーバーヘッド呼び出しなし呼び出しあり
安全性括弧や副作用に注意が必要比較的安全
読みやすさ単純なら短い複雑な処理に向く

この表を見ると、マクロは便利ですが、関数で書いたほうが安全で分かりやすい場面も多いことが分かります。

再帰関数とは

次に、再帰関数です。

再帰関数とは、関数の中で自分自身を呼び出す関数 のことです。

基本の形は次のようになります。

void func()
{
    if (終了条件) {
        return;
    }

    func();
}

再帰では、同じ関数を何度も呼び出して処理を進めます。
ただし、必ず 終了条件 を用意しなければなりません。
これがないと、無限に呼び出し続けてしまいます。

再帰の考え方

再帰は、「大きな問題を、少し小さな同じ問題として考える」書き方です。

たとえば階乗なら、

  • 5! = 5 × 4!
  • 4! = 4 × 3!
  • 3! = 3 × 2!
  • 2! = 2 × 1!
  • 1! = 1

のように考えられます。

つまり、n! は n × (n - 1)! と書けるわけです。
この形が、そのまま再帰関数に向いています。

サンプルプログラム

ここではシンプルな例として、1から n までの合計を再帰で求める関数 を作成します。

考え方は次のようになります。

  • n = 1 のときは 1
  • n > 1 のときは n + (n - 1 までの合計)

これをそのまま関数にすると、こう書けます。

ファイル名:13_22_3.c

// 再帰で1からnまでの合計を求めるプログラム
#include <stdio.h>

int sum_to_n(int n);

int main(void)
{
    int num = 5;

    printf("1から%dまでの合計 = %d\n", num, sum_to_n(num));

    return 0;
}

// 1からnまでの合計を再帰で計算する関数
int sum_to_n(int n)
{
    if (n > 1) {
        return n + sum_to_n(n - 1);
    }
    else {
        return 1;
    }
}

実行結果例

1から5までの合計 = 15

この再帰の流れを追ってみよう

sum_to_n(5) は、次のように呼び出されます。

  • sum_to_n(5) は 5 + sum_to_n(4)
  • sum_to_n(4) は 4 + sum_to_n(3)
  • sum_to_n(3) は 3 + sum_to_n(2)
  • sum_to_n(2) は 2 + sum_to_n(1)
  • sum_to_n(1) は 1 を返す

そのあと、値が戻りながら計算されます。

  • sum_to_n(2) = 2 + 1 = 3
  • sum_to_n(3) = 3 + 3 = 6
  • sum_to_n(4) = 4 + 6 = 10
  • sum_to_n(5) = 5 + 10 = 15

これが再帰の基本の流れです。

図で再帰の流れをイメージしよう

この図では、sum_to_n が自分自身を小さい値で呼び出し続け、最後に n = 1 で終了する様子を表しています。
そのあと、戻りながら加算結果が積み上がっていきます。
再帰では、この「呼び出しながら進み、戻りながら結果を作る」という流れが大切です。

再帰関数で大事なのは終了条件

再帰では、終了条件がとても重要です。
もし終了条件がなければ、関数は自分自身をずっと呼び続けてしまいます。

たとえば、次のような関数は危険です。

void func(void)
{
    func();
}

これでは終わりがありません。
再帰では必ず、

  • どこで止まるか
  • そのとき何を返すか

を先に考える必要があります。

再帰のメリット

再帰には、独特のよさがあります。

メリット内容
再帰的な問題を自然に書ける階乗、木構造、探索など
定義をそのままコードにしやすい数学的な関係を表しやすい
処理の形がすっきりすることがある問題によってはループより見やすい

特に、「同じ形の小さい問題に分けられる」問題では、再帰がとても自然です。

再帰のデメリット

一方で、再帰には注意点もあります。

デメリット内容
関数呼び出しが多いオーバーヘッドがある
スタックを多く使う呼び出しが深いと危険
場合によってはループのほうが分かりやすい単純な繰り返しには再帰が向かないこともある

たとえば、大きすぎる値で深い再帰をすると、スタックオーバーフローの原因になることがあります。

マクロと再帰をどう使い分けるか

ここまでの内容を簡単に整理すると、次のようになります。

テーマ向いている場面注意点
関数形式マクロ単純な式を短く書きたいとき括弧と副作用に注意
再帰関数再帰的な構造の問題終了条件とスタック消費に注意

どちらも便利ですが、いつでも使えばよいわけではありません。
便利さと危険さの両方を知ったうえで使い分ける のが大切です。

実践問題

次の仕様に従ってプログラムを作成してください。

仕様

  • 関数形式マクロ ABS(x) を定義する
  • x が 0 以上ならそのまま、負なら符号を反転して返す
  • main 関数で整数値を使って結果を表示する

実行結果例

絶対値: 12

解答例

ファイル名:13_22_4.c

// 関数形式マクロで絶対値を求めるプログラム
#include <stdio.h>

#define ABS(x) (((x) >= 0) ? (x) : -(x))

int main(void)
{
    int n = -12;

    printf("絶対値: %d\n", ABS(n));

    return 0;
}

解説

このマクロは、条件演算子を使って

  • 0 以上ならそのまま
  • 負なら符号を反転

という処理をしています。

引数 x や全体をしっかり括弧で囲っている点が大切です。

実践問題

次の仕様に従ってプログラムを作成してください。

仕様

  • 再帰関数 countdown を作る
  • n を受け取り、n から 1 まで表示する
  • n が 0 以下になったら終了する

実行結果例

5
4
3
2
1

解答例

ファイル名:13_22_5.c

// 再帰でカウントダウンするプログラム
#include <stdio.h>

void countdown(int n);

int main(void)
{
    countdown(5);

    return 0;
}

// n から 1 まで表示する関数
void countdown(int n)
{
    if (n <= 0) {
        return;
    }

    printf("%d\n", n);

    countdown(n - 1);
}

解説

この関数では、n が 0 以下なら return で終了します。
それ以外なら n を表示し、n - 1 で自分自身を呼び出します。

再帰関数では、このように 終了条件再帰呼び出し の2つをセットで考えることが大切です。

学習のコツ

関数の応用を学ぶときは、次のように考えると整理しやすいです。

ポイント意識したいこと
関数形式マクロ文字列の置き換えであることを忘れない
括弧マクロでは特に厳重に付ける
副作用++ などをマクロ引数に入れると危険なことがある
再帰必ず終了条件を用意する
再帰の流れ呼び出して進み、戻りながら結果が決まる

特に、マクロは「関数っぽく見えるけれど関数ではない」、再帰は「自分自身を呼び出すけれど必ず終わる条件が必要」という2点を意識すると、かなり理解しやすくなります。