前回は、 Ver.5を作成した。
TL;DR(要約)
- Ver.5 は「1行入力+保存+閲覧(過去2件/月次)」のシンプル版。
- Ver.6 は複数行入力(空行で終了)、UTF-8の“文字数”カウント、20〜100文字の制約を追加。
- 文字カウント仕様の追加により、ロケール初期化・マルチバイト処理・入力ループを中心に大改編。
- コンパイル方法も変更:POSIX マクロ定義で警告ゼロを推奨。
(検証環境)
PaizaCloud(無料プラン)
Distributor ID: Ubuntu
Description: Ubuntu 18.04.3 LTS
Release: 18.04
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0
背景:Ver.5 の仕様と課題
Ver.5 の仕様
- 1行入力(`fgets` で1行だけ受け取り保存)
- メニュー:過去2件表示/新規記録/月ごとの一覧/終了
- 保存形式YYYY-MM-DD(曜) hh:mm:ss]` の行+本文1行+空行
- 月次一覧:タイムスタンプの先頭7文字(`YYYY-MM`)でグルーピング
- ロケール`setlocale(LC_ALL, “ja_JP.UTF-8”)` を固定指定(環境によって失敗することがある)
Ver.5 の課題
- 複数行を書けない(空行で終了といった UI がない)
- 文字数の扱いが“バイト数”寄りで、日本語(UTF-8)の「1文字」と一致しない可能性
- ロケールを固定名で指定するため、環境差(該当ロケール未導入)で不安定になり得る
Ver.6 の仕様追加
- 複数行入力(空行で終了)
- 入力のたびに追記後の「現在の文字数」を表示
- 20〜100文字の制約
20文字未満 → 保存不可
100文字超 →超過分は保存しない(その行の取り込みを制限) - UTF-8 での“文字数”カウント(日本語の 1 文字 ≠ バイト数)
- ロケール初期化の堅牢化(環境差で壊れない)
設計の要点
- 「文字数」の定義をUnicode の“文字”(コードポイント)寄りにし、`mbrtowc` を用いて UTF-8 のマルチバイトを1文字として数える
- 改行はカウントしない(仕様として固定)
- ロケールは `setlocale(LC_ALL, “”)` を起点に UTF-8 を確保(`ja_JP.UTF-8` → `C.UTF-8` → `en_US.UTF-8` フォールバック)
- mbrtowc` エラー時にループを打ち切らず、1バイト進めて復帰(混入バイトで全体が 0 文字にならないように)
- 保存形式は Ver.5 と互換(`[timestamp]` → 本文 → 空行)
既存 `diary.txt` をそのまま利用可
#define _POSIX_C_SOURCE 200809L // setenv, tzset などの宣言を可視化(警告対策)
// diary_ver6.c - UTF-8対応・100文字制限・マルチバイト文字数カウント・ロケール堅牢化版
// Ver.5 → Ver.6 での大改編ポイント:複数行入力、UTF-8文字数カウント、20〜100文字制約、ロケール強化
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <locale.h>
#include <time.h>
#include <wchar.h>
#include <wctype.h>
#if defined(__linux__) || defined(__APPLE__)
#include <langinfo.h>
#define HAVE_LANGINFO 1
#endif
#define MAX_ENTRY 1000
#define MAX_LINE 1024
#define CONTENT_MAX (MAX_LINE * 10)
typedef struct {
char content[CONTENT_MAX];
char month[16]; // "YYYY-MM" または "未記録"
} DiaryEntry;
/* UTF-8 ロケールを確実に使うための初期化 */
static void init_locale_utf8(void) {
const char *ok = NULL;
// 環境のロケール(LANG/LC_*)を優先
ok = setlocale(LC_ALL, "");
// 代表的なUTF-8ロケールにフェイルオーバー
if (!ok) ok = setlocale(LC_ALL, "ja_JP.UTF-8");
if (!ok) ok = setlocale(LC_ALL, "C.UTF-8");
if (!ok) ok = setlocale(LC_ALL, "en_US.UTF-8");
#if defined(HAVE_LANGINFO)
if (ok) {
const char *cs = nl_langinfo(CODESET);
if (!cs || (strcmp(cs, "UTF-8") != 0 && strcmp(cs, "UTF8") != 0)) {
fprintf(stderr,
"警告: 現在のロケールはUTF-8ではありません(CODESET=%s)。"
" 日本語の文字数カウントが正しく動作しない可能性があります。\n",
cs ? cs : "(不明)");
}
} else {
fprintf(stderr,
"警告: UTF-8ロケールの設定に失敗しました。"
" OS に ja_JP.UTF-8 / C.UTF-8 などを導入してください。\n");
}
#else
if (!ok) {
fprintf(stderr,
"警告: ロケールの設定に失敗しました(UTF-8未設定)。\n");
}
#endif
}
/* マルチバイト文字を1文字としてカウント(改行除外) */
static size_t count_characters(const char *s) {
size_t count = 0;
wchar_t wc;
mbstate_t state;
memset(&state, 0, sizeof(state));
while (*s) {
size_t len = mbrtowc(&wc, s, MB_CUR_MAX, &state);
if (len == (size_t)-1) { // 不正シーケンス:1バイト進めて継続
++s;
memset(&state, 0, sizeof(state));
continue;
}
if (len == (size_t)-2) { // 不完全(末尾)
break;
}
if (len == 0) { // 終端
break;
}
if (wc != L'\n' && wc != L'\r') count++;
s += len;
}
return count;
}
/* 空行(空白/タブ/CRのみ + LF or 終端)を判定
※全角スペース(U+3000)は空白扱いにしていない点に注意 */
static int is_blank_line(const char *s) {
const unsigned char *p = (const unsigned char *)s;
while (*p == ' ' || *p == '\t' || *p == '\r') p++;
return (*p == '\n' || *p == '\0');
}
static void make_timestamp(char *buf, size_t bufsize) {
time_t now = time(NULL);
struct tm *t = localtime(&now);
const char *weekdays[] = {"日","月","火","水","木","金","土"};
snprintf(buf, bufsize, "%04d-%02d-%02d(%s) %02d:%02d:%02d",
t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
weekdays[t->tm_wday], t->tm_hour, t->tm_min, t->tm_sec);
}
/* 先頭の [YYYY-MM-...] から YYYY-MM を安全に抽出 */
static void extract_month(const char *entry_text, char out[16]) {
const char *start = strchr(entry_text, '[');
if (!start) { strcpy(out, "未記録"); return; }
char y[5] = {0}, m[3] = {0};
if (sscanf(start + 1, "%4[0-9]-%2[0-9]", y, m) == 2) {
snprintf(out, 16, "%s-%s", y, m);
} else {
strcpy(out, "未記録");
}
}
int read_diary_entries(const char *filename, DiaryEntry *entries, int max_entries) {
FILE *fp = fopen(filename, "r");
if (!fp) return 0;
char line[MAX_LINE];
char buffer[CONTENT_MAX]; buffer[0] = '\0';
int entry_count = 0;
while (fgets(line, sizeof(line), fp)) {
if (is_blank_line(line)) {
if (buffer[0] != '\0' && entry_count < max_entries) {
strncpy(entries[entry_count].content, buffer, CONTENT_MAX - 1);
entries[entry_count].content[CONTENT_MAX - 1] = '\0';
extract_month(buffer, entries[entry_count].month);
entry_count++;
buffer[0] = '\0';
}
} else {
size_t cur = strlen(buffer);
size_t rem = (CONTENT_MAX - 1) - cur;
if (rem > 0) strncat(buffer, line, rem);
}
}
if (buffer[0] != '\0' && entry_count < max_entries) {
strncpy(entries[entry_count].content, buffer, CONTENT_MAX - 1);
entries[entry_count].content[CONTENT_MAX - 1] = '\0';
extract_month(buffer, entries[entry_count].month);
entry_count++;
}
fclose(fp);
return entry_count;
}
static void show_menu(void) {
printf("\n********** MENU **********\n");
printf("1. 過去2件の日記を表示する\n");
printf("2. 新しい日記を書く(複数行)\n");
printf("3. 月ごとの一覧を表示する\n");
printf("9. 終了する\n");
printf("***************************\n");
printf("選択肢を入力してください:");
}
static void show_latest_two_entries(void) {
DiaryEntry *entries = (DiaryEntry *)calloc(MAX_ENTRY, sizeof(DiaryEntry));
if (!entries) { puts("メモリ確保に失敗しました。"); return; }
int count = read_diary_entries("diary.txt", entries, MAX_ENTRY);
printf("\n=== 過去2件(日付が新しい順) ===\n");
if (count <= 0) {
printf("(過去の日記はありません)\n");
free(entries);
return;
}
int start = (count >= 2) ? count - 2 : 0;
for (int i = count - 1; i >= start; --i) {
printf("%s\n", entries[i].content);
if (i > start) puts("--------------------------------");
}
free(entries);
}
static void show_monthly_summary(void) {
DiaryEntry *entries = (DiaryEntry *)calloc(MAX_ENTRY, sizeof(DiaryEntry));
if (!entries) { puts("メモリ確保に失敗しました。"); return; }
int count = read_diary_entries("diary.txt", entries, MAX_ENTRY);
if (count <= 0) {
printf("\n(日記がありません)\n");
free(entries);
return;
}
printf("\n=== 月ごとの一覧(古い→新しい) ===\n");
char current_month[16] = "";
for (int i = 0; i < count; i++) {
if (strcmp(current_month, entries[i].month) != 0) {
strncpy(current_month, entries[i].month, sizeof(current_month)-1);
current_month[sizeof(current_month)-1] = '\0';
printf("\n■ %s の日記:\n", current_month[0] ? current_month : "未記録");
}
printf("%s\n", entries[i].content);
}
free(entries);
}
static void write_new_entry(void) {
char diary[CONTENT_MAX] = "";
char line[MAX_LINE];
printf("\n=== 今日の日記を入力してください(空行で終了) ===\n");
while (fgets(line, sizeof(line), stdin)) {
if (is_blank_line(line)) break;
size_t cur_len = count_characters(diary);
if (cur_len >= 100) {
puts("※ すでに100文字に達したため、これ以上は記録されません。");
break;
}
size_t remaining = 100 - cur_len;
size_t copy_len = 0;
wchar_t wc;
mbstate_t state;
memset(&state, 0, sizeof(state));
const char *p = line;
while (*p && remaining > 0) {
size_t len = mbrtowc(&wc, p, MB_CUR_MAX, &state);
if (len == (size_t)-1) { // 不正シーケンス:1バイト飛ばして継続
++p;
memset(&state, 0, sizeof(state));
continue;
}
if (len == (size_t)-2 || len == 0) { // 不完全/終端
break;
}
if (wc != L'\n' && wc != L'\r') remaining--;
copy_len += len;
p += len;
}
strncat(diary, line, copy_len);
printf("(現在 %zu 文字 / 最大100文字)\n", count_characters(diary));
}
size_t len = count_characters(diary);
if (len < 20) {
puts("内容が短すぎます。20文字以上で入力してください。");
return;
}
char timestamp[128];
make_timestamp(timestamp, sizeof(timestamp));
FILE *fp = fopen("diary.txt", "a");
if (!fp) {
perror("ファイルを開けませんでした");
return;
}
fprintf(fp, "[%s]\n%s\n\n", timestamp, diary);
fclose(fp);
puts("日記を保存しました。");
}
int main(void) {
setenv("TZ", "Asia/Tokyo", 1);
tzset();
init_locale_utf8(); // UTF-8 ロケールを確保
int choice;
for (;;) {
show_menu();
if (scanf("%d", &choice) != 1) {
puts("数字を入力してください。");
int ch; while ((ch = getchar()) != '\n' && ch != EOF) {}
continue;
}
int ch; while ((ch = getchar()) != '\n' && ch != EOF) {}
switch (choice) {
case 1: show_latest_two_entries(); break;
case 2: write_new_entry(); break;
case 3: show_monthly_summary(); break;
case 9: puts("終了します。"); return 0;
default: puts("無効な選択です。もう一度入力してください。");
}
}
return 0;
}
コンパイル方法(変更点あり)
文字カウント仕様の追加に伴い、POSIX 関数(setenv
, tzset
)の宣言を確実に可視化するため、
マクロ定義を付けてビルドすることを推奨する。
推奨コマンド(Linux / macOS / PaizaCloud)
gcc -std=c11 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -O2 diary_ver6.c -o diary
代替:-D_XOPEN_SOURCE=700
でも同等の効果がある。
macOS(clang)でも同じ指定で OK。
実行環境に関する補足(PaizaCloud/Ubuntu 18 系)
PaizaCloud(Ubuntu 18 系)では、デフォルトで en_US.UTF-8
が有効(UTF-8)。locale
の出力が *.UTF-8
なら、mbrtowc
による文字数カウントは正常動作する。
実行例
=== 今日の日記を入力してください(空行で終了) ===
今日はめちゃくちゃ暑かった。
(現在 14 文字 / 最大100文字)
明日こそ涼しくなってほしい。
(現在 28 文字 / 最大100文字)
でもどうなるかな?
(現在 37 文字 / 最大100文字)
日記を保存しました。
=== 過去2件(日付が新しい順) ===
[2025-08-23(土) 12:55:39]
今日はめちゃくちゃ暑かった。
明日こそ涼しくなってほしい。
でもどうなるかな?
互換性と移行メモ(Ver.5 → Ver.6)
- 保存形式は互換:
[timestamp]
→ 本文 → 空行 の構造を継承。既存diary.txt
はそのまま利用可能である。 - 文字数の扱い:Ver.6 では「UTF-8 の“文字”」としてカウント(改行は除外)する。
- 入力 UI:1行 → 複数行(空行で終了) へ。追記時に「現在文字数」を表示する。
- 20〜100文字の範囲内で保存。
- ロケール:固定
"ja_JP.UTF-8"
依存から脱却し、環境に合わせて UTF-8 を確保(フォールバック内蔵)。
よくある質問(FAQ)
Q1. 「(現在 0 文字)」のまま増えない。
A. 端末/プロセスのロケールが UTF-8 になっていない可能性。locale
で *.UTF-8
を確認。
Ver.6 は init_locale_utf8()
で UTF-8 を確保するが、OS 側に UTF-8 ロケールが未導入の場合は導入が必要。
Q2. setenv
/ tzset
に警告が出る。
A.POSIX マクロを付けてビルドしてください。gcc -std=c11 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -O2 ...
Q3. Windows(MSVC)でビルドしたい。
A. 本記事は Linux / macOS 前提である。
Q4. 全角スペースだけの行を「空行」とみなしたい。
A. 現状は半角空白/タブ/CR のみを空白扱い。
コメント