🌏

【C言語】 Hello, world! 以前 〜 printf 関数を自作してみた〜

に公開

Hello, world! ✋

printf("Hello, world!\n");

7 年前、ここから私のプログラミングライフが始まりました。

プログラミングの基本のキとして扱われがちな Helllo, world! ですが、ここで使っている標準出力関数ってかなりリッチですよね?

例えば、C 言語の printf 関数は、

  • 可変長引数を受け取れる
  • フォーマット指定子(%d など)に変数を埋め込める
  • 文字によってバイトサイズの異なる UTF-8 を扱える

etc...
かなり複雑な作りになっていることが想像できます 🤔

上記 3 点を満たす簡易版 printf 関数を自作してみました!

成果物 🏁

https://github.com/kamata-bug-factory/my-printf

ポイント 💡

1. 可変長引数を受け取る 🎁

void my_printf(const char *format, ...) {
    va_list args;
    va_start(args, format);

    while (*format) {
        if (*format == '%') {
            format++;
            switch (*format) {
                case 'd': my_putint(va_arg(args, int)); break;
                case 's': my_puts(va_arg(args, char*)); break;
                case 'c': {
                    char c = (char)va_arg(args, int);
                    write(1, &c, 1);
                    break;
                }
                case '%': write(1, "%", 1); break;
                default: write(1, "?", 1); break;
            }
        } else {
            my_put_utf8_char(&format);
            continue;
        }
        format++;
    }

    va_end(args);
}

1.1 va_list

可変長引数を格納するための変数。

va_list args;

これを使って可変長引数を操作します。

1.2 va_start

可変長引数の処理を開始します。

va_start(args, format);
  • args を初期化する
  • format(最初の固定引数) の次の引数から取得できる

1.3 va_arg

可変長引数の次の引数を取得します。

int value = va_arg(args, int);
  • 呼び出すたびに次の引数を取得する
  • 型は int, double, char* など 正しく指定する必要がある

1.4 va_end

可変長引数の処理を終了します。
va_start を呼んだら必ず va_end で終了します。

va_end(args);

2. フォーマット指定子に変数を埋め込む 👷

my_printf 関数では、format の文字を 1 文字ずつ処理します。
format++ でポインタをインクリメントすることで、1 バイトずつ処理できます。

以下の流れでフォーマット指定子を処理します。

2.1 if (*format == '%') でフォーマット指定子を検出

format の現在の文字が '%' である場合、次の文字を確認するために format++ します。

if (*format == '%') {
    format++;

2.2 switch 文でフォーマット指定子を判定

switch (*format) で、次の文字が d(整数)、s(文字列)、c(文字)、%(リテラルの %)のどれなのかを判定します。

switch (*format) {

2.3 %d(整数)の処理

整数型 intva_arg で取得し、my_putint に渡します。

case 'd':
    my_putint(va_arg(args, int));
    break;
  • va_arg(args, int) によって args から int 型の値を取り出す
  • 取り出した値を my_putint に渡し、整数を文字列に変換して write で出力する

2.4 %s(文字列)の処理

文字列のポインタ(char *)を va_arg で取得し、my_puts に渡します。

case 's':
    my_puts(va_arg(args, char*));
    break;
void my_puts(const char *s) {
    write(1, s, strlen(s));
}
  • va_arg(args, char*) によって args から char * 型(文字列の先頭アドレス)を取得する
  • my_putswrite(1, s, strlen(s)); で文字列全体を出力する

2.5 %c(文字)の処理

文字 char を取得し、write で直接出力します。

case 'c': {
    char c = (char)va_arg(args, int);
    write(1, &c, 1);
    break;
}
  • va_arg(args, int)int 型の値を取得し、char 型にキャストする
    • 可変長引数を使うとき、char, short などの小さいデータ型は int 型に昇格するため(default argument promotion)
  • write(1, &c, 1); で 1 バイトだけ出力する

2.6 %%(リテラルの %)の処理

%% を見つけた場合、write(1, "%", 1);% をそのまま出力します。

case '%':
    write(1, "%", 1);
    break;

2.7 default の処理

サポートされていないフォーマット指定子の場合、? を出力します。

default:
    write(1, "?", 1);
    break;

write の使い方 🧑‍🏫

int write(int fd, void *buf, unsigned int byte)
引数
引数 説明
fd(ファイルディスクリプタ) 書き込む対象(1 = 標準出力, 2 = 標準エラー)
buf(データバッファ) 書き込むデータのアドレス
byte(バイト数) 書き込むデータのサイズ
戻り値
  • 成功: 書き込んだバイト数
  • 失敗: -1

3. my_putint の処理 🔢

3.1 バッファの準備

int 型は -2147483648 〜 2147483647 の最大 11 桁。
これに終端ヌル文字 \0 を加えた 12 バイト確保します。

char buf[12];

3.2 負の数の扱い

負の数フラグを立て、絶対値を保持します。

if (n < 0) {
    is_negative = 1;
    n = -n;
}

3.3 数値を逆順にバッファに格納

数値を逆順でバッファに格納します。

ex. 1234["4", "3", "2", "1"]

if (n == 0) {
    buf[i++] = '0';
} else {
    while (n > 0) {
        buf[i++] = (n % 10) + '0';
        n /= 10;
    }
}
  • n % 10 で一番右の桁を取得し、それを '0''9' に変換する
  • n /= 10 で次の桁へ移動する

3.4 負の数なら '-' を追加

負の数のとき、末尾に '-' を追加します。

ex. -56789["9", "8", "7", "6", "5", "-"]

if (is_negative) {
    buf[i++] = '-';
}

3.5 バッファの反転

バッファの左右を反転します。

for (int j = 0; j < i / 2; j++) {
    char temp = buf[j];
    buf[j] = buf[i - j - 1];
    buf[i - j - 1] = temp;
}

ex. -56789

buf の状態 操作
["9", "8", "7", "6", "5", "-"] buf[0]buf[5] (9-)
["-", "8", "7", "6", "5", "9"] buf[1]buf[4] (85)
["-", "5", "7", "6", "8", "9"] buf[2]buf[3] (76)
["-", "5", "6", "7", "8", "9"] 完了

3.6 文字列終端を追加

C 言語の文字列として扱うため、最後にヌル文字 '\0' を追加します。

buf[i] = '\0';

3.7 write で出力

整数を標準出力に書き込みます。
終端ヌル文字 \0 は除いています。

write(1, buf, i);

4. UTF-8 を扱う 🇯🇵

ASCII 文字は format++ でポインタを 1 バイトずつ進めることで 1 文字ずつ処理できますが、UTF-8 文字は 2 〜 4 バイトの文字も存在するため、何バイトの文字か判定する必要があります。

my_put_utf8_char では次の文字が何バイトの文字か判定し、1 文字ずつ文字を出力します。

4.1 文字列の先頭バイトを取得

void my_put_utf8_char(const char **p) {
    const unsigned char *s = (const unsigned char *)(*p);
  • pconst char**(文字列のポインタへのポインタ)
  • *p で指している先頭文字のポインタを取得
  • sunsigned char* にキャストして扱う
    • char は 符号付き(signed char) の可能性があり、ビットパターンが負の値として解釈されないようにするため
    • ex. "こ"の先頭バイト: 0xE3(11100011)-29

4.2 先頭バイトを見て、UTF-8 のバイト数を判定

if ((*s & 0x80) == 0)        bytes = 1;  // ASCII (1バイト)
else if ((*s & 0xE0) == 0xC0) bytes = 2;  // 2バイト文字
else if ((*s & 0xF0) == 0xE0) bytes = 3;  // 3バイト文字
else if ((*s & 0xF8) == 0xF0) bytes = 4;  // 4バイト文字
ビットパターンによる判定

以下のように、UTF-8 の文字は先頭バイトのビットパターンで、何バイトの文字かが決まっています。

文字の種類 先頭バイトのビットパターン バイト数
ASCII 0xxxxxxx 1
2 バイト 110xxxxx 2
3 バイト 1110xxxx 3
4 バイト 11110xxx 4

& 演算を使って、このビットパターンに一致するかどうかを判定しています。

各判定の意味
  • (*s & 0x80) == 0
    • 0x8010000000)との論理積をとる
    • 結果が 0 なら ASCII(1 バイト)
    • ex. 'A'010000010
  • (*s & 0xE0) == 0xC0
    • 0xE011100000)との論理積をとる
    • 結果が 0xC011000000)なら 2 バイト
    • ex. "あ"11000011 1000001011000000
  • (*s & 0xF0) == 0xE0
    • 結果が 0xE011100000)なら 3 バイト
    • ex. "漢"11101000 10000010 1001001011100000
  • (*s & 0xF8) == 0xF0
    • 結果が 0xF011110000)なら 4 バイト
    • ex. 絵文字 "🌍"11110000 10011111 10011000 1000110011110000

4.3 文字を出力

write(1, s, bytes);
  • write を使って判定したバイト数 bytes だけ出力する
  • s は先頭バイトのポインタなので、bytes バイトだけ出力することで 1 文字を正しく表示できる

4.4 ポインタを進める

*p += bytes;
  • *p(呼び出し元のポインタ)を bytes バイトだけ進める
  • これにより、次のループで次の文字の先頭バイトを処理できる

動作確認

int main() {
    setlocale(LC_ALL, "");  // UTF-8 のロケール設定

    my_printf("ゼロ: %d\n", 0);
    my_printf("正の数: %d\n", 1234);
    my_printf("負の数: %d\n", -56789);

    my_printf("英語: %s\n", "Hello, world!");
    my_printf("日本語: %s\n", "こんにちは 世界");

    my_printf("char: %c\n", 'A');
    my_printf("%%\n");
    my_printf("%a\n");

    return 0;
}

コンパイル

gcc my-printf.c -o my-printf

実行

./my-printf

実行結果

ゼロ: 0
正の数: 1234
負の数: -56789
英語: Hello, world!
日本語: こんにちは 世界
char: A
%
?

ちゃんと動きました 🙌

おわりに

printf 関数が実際はどのような実装になっているか知りませんが、かなり複雑なことをしていそうです 🙈

今回は以下を実装しませんでしたが、これらも実装しようとすると大変そうです 😵‍💫

  • 単精度浮動小数点数 %f、倍精度浮動小数点数 %lf などのフォーマット指定子
  • 幅・精度指定(ex. %5d, %.3f

プログラミングの世界への扉を叩いたときの気持ちを思い出して、引き続き精進します ✊

おすすめしたい C 言語の名著 📖

Discussion