Hello, world! ✋
printf("Hello, world!\n");
7 年前、ここから私のプログラミングライフが始まりました。
プログラミングの基本のキとして扱われがちな Helllo, world! ですが、ここで使っている標準出力関数ってかなりリッチですよね?
例えば、C 言語の printf
関数は、
- 可変長引数を受け取れる
- フォーマット指定子(
%d
など)に変数を埋め込める - 文字によってバイトサイズの異なる UTF-8 を扱える
etc...
かなり複雑な作りになっていることが想像できます 🤔
上記 3 点を満たす簡易版 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
(整数)の処理
整数型 int
を va_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_puts
はwrite(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] (8 ↔ 5 ) |
["-", "5", "7", "6", "8", "9"] |
buf[2] ↔ buf[3] (7 ↔ 6 ) |
["-", "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);
-
p
はconst char**
(文字列のポインタへのポインタ) -
*p
で指している先頭文字のポインタを取得 -
s
をunsigned 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
-
0x80
(10000000
)との論理積をとる - 結果が
0
なら ASCII(1 バイト) - ex.
'A'
(01000001
→0
)
-
-
(*s & 0xE0) == 0xC0
-
0xE0
(11100000
)との論理積をとる - 結果が
0xC0
(11000000
)なら 2 バイト - ex.
"あ"
(11000011 10000010
→11000000
)
-
-
(*s & 0xF0) == 0xE0
- 結果が
0xE0
(11100000
)なら 3 バイト - ex.
"漢"
(11101000 10000010 10010010
→11100000
)
- 結果が
-
(*s & 0xF8) == 0xF0
- 結果が
0xF0
(11110000
)なら 4 バイト - ex. 絵文字
"🌍"
(11110000 10011111 10011000 10001100
→11110000
)
- 結果が
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 言語の名著 📖
-
苦しんで覚える C 言語
https://9cguide.appspot.com/