マルチバイト文字

「文字」のバイト数

英語圏で使用される文字は種類が少ないので、1バイト(256種)の情報量ですべての文字を表すことができます。
ASCIIでは制御文字も含めて0~127に文字を割り当てており、char型ですべての文字を扱うことができます。

しかし日本語などは文字種が多く、1バイトでは足りないので2バイト以上を使用してひとつの文字を表します。
これをマルチバイト文字といいます。
1バイトのみで表せる文字をシングルバイト文字といいます。

マルチバイト文字はC言語ではchar型の配列で扱うことができます。
日本語一文字を表すには2バイト(以上)必要なので、「あ」という文字を格納するだけでもchar型配列を使用する必要があります。

#include <stdio.h>

int main()
{
	char str1[] = "A";
	char str2[] = "あ";

	printf("%d\n", sizeof(str1));
	printf("%d\n", sizeof(str2));

	getchar();
}
2
3

Shift_JISではアルファベット(半角)は一文字で1バイト、日本語は一文字で2バイトが必要です。
どちらも配列の終端にNULL文字が入っているので、文字の格納に必要なバイト数+1の配列サイズになっています。

sizeof演算子は単に配列のサイズを返すだけです。
プログラム内で使用する文字列は日本語のみ、または英数字のみと決まっているのならばそれほど問題はありませんが、これらが混在する場合に正確な文字数を得ることはできません。

文字コードによる実行結果の違い

上記の実行結果はWindows+VisualStudioでコードを実行した場合です。
同じコードをUTF-8で扱う環境でコンパイル&実行すると「2」と「4」が出力されるはずです。
UTF-8ではアルファベットは1バイトですが日本語は3バイトで表すためです。

つまりUTF-8の環境では日本語を100文字格納するために必要なchar型配列のサイズは200(+NULL文字)ではなく300ということです。
文字コードによって一文字に必要なバイト数が変わることは知っておいた方が良いです。

ちなみにShift_JISの半角カナは1バイトですが、EUC-JPの場合は2バイトです。

マルチバイト文字の文字数の取得

文字列の長さの取得の項では、文字数のカウントにはstrlen関数か_mbstrlen関数を使用すると説明しました。
strlen関数はマルチバイト文字を想定していないので結局はバイト数を返しますが(NULL文字が出現するまでのバイト数)、_mbstrlen関数はマルチバイト文字を一文字と数え、正しい文字数を返します。

しかし_mbstrlen関数はVisualStudioでは使用できますが、他のコンパイラでは使用できません。
_mbslenという類似関数もあるのですが、これもVisualStudioでしか使用できません。
(先頭にアンダースコアが付けられている関数はマイクロソフト独自の拡張です)

また、マルチバイト文字を扱う関数を使用する場合、事前にsetlocale関数で文字コードを指定するのですが、Windows環境ではどうもUTF-8が設定できないようです。
つまり_mbstrlen関数をはじめとするマルチバイト系の関数は、Windows環境ではUTF-8を上手く扱えません。

標準関数にmblenという関数があり、これはマルチバイト文字のバイト数を返します。
(「文字列」ではなく一文字のバイト数)
これは<stdlib.h>をインクルードすることで使用できます。

出現する文字毎のバイト数が分かれば文字列全体の文字数も判別できるはずです。
しかしmblen関数もsetlocale関数が必要なので、そのままではすべての環境に対応することは困難です。
そこで、setlocale関数で文字コードを指定できる場合はmblen関数を使用し、使用できない場合は自作関数を使用することにします。
以下はその例です。

//マルチバイト文字列の文字数を数える

#include <stdio.h>
#include <stdlib.h>
#include <locale.h>

//ファイルの最大サイズ
#define BUFFER 1024

//UTF-8の文字チェックに使用する
//2進数で1000_0000
#define MSB1 0x80

//UTF-8のBOM
const unsigned char UTF8BOM[] = { 0xef, 0xbb, 0xbf };

//文字コードの種類
enum CharacterCode
{
	SJIS,
	EUCJP,
	UTF8
};

//ファイルを読み取り、終端にNULL文字を付加した文字列を
//第二引数の配列に格納する
//戻り値:
//成功=文字列のサイズ
//失敗=負数
int ReadTextFile(const char* file, char str[])
{
	//fopen_s関数がない環境用に
	//fopen関数を使用
	FILE *fp = fopen(file, "r");
	if (fp == NULL)
		return -1;

	//バイナリで読み取り
	int size = fread(str, sizeof(char), BUFFER - 1, fp);
	fclose(fp);

	//データの終端にNULL文字を追加
	str[size] = '\0';

	return size + 1;
}

//UTF-8のBOMチェック
//BOM付きなら0以外を返す
int CheckBOM_UTF8(const char* str)
{
	for (int i = 0; i < 3; i++)
	{
		if ((unsigned char)*(str + i) != UTF8BOM[i])
		{
			return -1;
		}
	}
	return 0;
}

//Shift_JIS
//文字のバイト数を返す
int CharacterByte_SJIS(const char* s, size_t n)
{
	unsigned char c = (unsigned char)*s;
	if (((c >= 0x81) && (c <= 0x9f)) || ((c >= 0xe0) && (c <= 0xfc)))
		return 2;
	return 1;
}

//EUC-JP
//文字のバイト数を返す
int CharacterByte_EUCJP(const char *s, size_t n)
{
	unsigned char c = (unsigned char)*s;
	if (c == 0x8f)
		return 3;
	if (((c >= 0xa1) && (c <= 0xfe)) || c == 0x8e)
		return 2;
	return 1;
}

//UTF-8
//文字のバイト数を返す
int CharacterByte_UTF8(const char *s, size_t n)
{
	unsigned char c = (unsigned char)*s;
	if (!(c & MSB1))
		return 1;
	c <<= 2;
	if (!(c & MSB1))
		return 2;
	c <<= 1;
	if (!(c & MSB1))
		return 3;
	return 4;
}

//マルチバイト文字列の文字数を返す
//NULL文字がない文字列が渡される可能性を考慮して
//len以上のバイト数は読み取らない
int MyMBStrlen(const char *str, int len, enum CharacterCode code)
{
	int(*func)(unsigned char*, size_t); //関数ポインタ

	//文字コードに合わせてロケールを設定
	//setlocale関数が成功すればmblen関数を使用
	//失敗した場合は自作関数を使用
	switch (code)
	{
	case SJIS:
		if (setlocale(LC_ALL, ".932") == NULL &&
			setlocale(LC_ALL, "ja_JP.sjis") == NULL)
			func = CharacterByte_SJIS;
		else
			func = mblen;
		break;
	case EUCJP:
		if (setlocale(LC_ALL, ".20932") == NULL &&
			setlocale(LC_ALL, "ja_JP.eucjp") == NULL)
			func = CharacterByte_EUCJP;
		else
			func = mblen;
		break;
	case UTF8:
		if (setlocale(LC_ALL, ".65001") == NULL &&
			setlocale(LC_ALL, "ja_JP.utf8") == NULL)
			func = CharacterByte_UTF8;
		else
			func = mblen;
		break;
	default:
		return 0;
	}

	int count = 0; //文字数
	int byte; //一文字のバイト数

	//NULL文字が出現するか
	//読み取ったバイト数がlenに達するまでループ
	while (*str != '\0' && len > 0) 
	{
		byte = func(str, MB_CUR_MAX);
		if (byte <= 0) //0以下が返ってきたらエラーとする
			return 0;

		//一文字のバイト数だけポインタを進める
		str += byte;

		len -= byte;
		count++;
	}

	return count;
}

int main()
{
	//読み込むファイル名を指定
	const char *file = "test.txt";

	//このプログラムは文字コードを自動判別してくれるわけではない
	//読み込むファイルの文字コードを指定
	enum CharacterCode code = SJIS;

	char str[BUFFER];
	int length = ReadTextFile(file, str);
	if (length < 0)
	{
		printf("%sのオープンに失敗しました。\n", file);
		printf("Enterキーで終了。\n");
		getchar();
		return 0;
	}

	//UTF-8ならばBOMチェック
	//BOM付きならポインタを3進める
	char* pstr;
	if (code == UTF8 && CheckBOM_UTF8(str) == 0)
	{
		pstr = str + 3;
		length -= 3;
	}	
	else
	{
		pstr = str;
	}

	//コンソール画面は現在の環境以外の文字コードは
	//文字化けするので注意
	printf("%s\n", pstr);

	printf("%d", MyMBStrlen(pstr, length, code));

	getchar();
}

適当なテキストファイルを用意して、ファイルのエンコード(文字コード)に合わせてMyMBStrlen関数の引数を変更することで適切な文字数を得ることができます。
(このコードでは配列のサイズを1024で決め打ちしているので、それ以上のサイズのファイルの場合は途中で文字列が切れます)

setlocale関数の第二引数には言語と文字コードを設定します。
設定に失敗するとNULLを返します。
ここにできるだけ使用できそうな値を指定し、成功した場合はmblen関数を使用します。
失敗した場合は自作関数を使用します。

setlocale関数の第二引数に指定している数値はコードページというものです。
詳しくは以下を参考にしてください。
setlocale、_wsetlocale | Microsoft Docs
Code Page Identifiers - Windows applications | Microsoft Docs

mblen関数の第二引数には現在の文字コードが一文字で使用する最大のバイト数を指定します。
これはMB_CUR_MAXという定数がありますので、それを指定します。

バイト数判定の自作関数はマルチバイト文字の先頭のバイトを見てその文字のバイト数を判別しています。
第二引数は使用しないのですが、mblen関数との整合性のために設定しています。

mblen関数も自作関数も一文字に使用されているバイト数を返しますので、戻り値の分だけポインタを進めることで次の文字の先頭バイトにポインタ位置をセットしています。
自作関数は失敗時の判定がありませんが、mblen関数は失敗すると-1を、NULL文字だった場合は0を返しますので、その場合はそこで判定を打ち切ります。

判定に使用する値は以下のサイトを参考にしています。

このサンプルコードはそれぞれの文字コードでの正しい文字列を先頭から順に読んだ場合にのみ処理できます。
マルチバイト文字の途中(2バイト目以降)から読み込むことはできません。
文字列データが壊れている場合も正しい結果となりません。

ちなみに当方のテスト環境ではWindows10ではUTF-8の処理に、CentOS7ではShift_JISの処理にそれぞれ自作関数が使用されました。