カレンダー入門
はじめに
新人教育などでプログラムを書かせる場合、その人がイメージしやすい問題を与えるということが重要です
ですから、まったくの初心者には「関数の仕様を渡して、その仕様通りの動作をする関数を書かせる」といった問題や「printf や malloc を書いてみよう」といった問題よりは「電卓を書いてみよう」「カレンダーを書いてみよう」といった問題の方が適切です
(そういったステップを乗り越えた後は printf や malloc を書かせるのはとてもよい課題です)
電卓やカレンダーならどんな初心者でも動作の細部まで完成形をイメージできますから、書いているプログラムが今どこまでできていて次に何をしなければいけないのか、といったことも容易にイメージすることができます
初心者に課題を出す場合にはここが重要なところで、ここをイメージできないままに放っておくと、宿題だから仕事だからやらなきゃいけないから仕方なくやる、何がわからないかもわからない、というようなことにもなってしまいます
また電卓やカレンダーのいいところは、初心者でも作れるけれども、作りこんでゆくと相当に奥が深い、というところにもあります
電卓は構文解析やスクリプトなどへも繋がりますし、カレンダーは休日などを自動的に判定するような機能を入れようとするとここに説明するようにかなり面倒で物理や天文などいろいろな知識が必要にもなってきます
日時値
シリアルな、リニアな日時を表す型はいろいろありますが、どんなものにしても必ず基点日時と時間解像度を決めます
FILETIME であれば、基点日時は 1601-01-01 00:00:00、時間解像度は 100ナノ秒、time_t であれば、基点日時は 1970-1-1 00:00:00、時間解像度は 1秒ですね
gmtime を実装してみる
このようなリニアな日時値は計算には便利なのですが、その値そのものを人間が見ても理解できないので、人間用には年月日時分秒で表示してあげる必要があります
このような変換は Win32 API の FileTimeToSystemTime や標準 C ライブラリ関数の gmtime などの関数がやってくれます
ではこの FileTimeToSystemTime や gmtime は何をやっているのでしょうか
これを考えるためにここでは gmtime を実装してみましょう
struct tm { int tm_sec; int tm_min; int tm_hour; int tm_mday; int tm_mon; int tm_year; int tm_wday; int tm_yday; int tm_isdst; }; struct tm* gmtime( const time_t* pt );
まず時分秒は簡単ですね
time_t の時間解像度は 1秒なので、time_t の値を 60 で割った余りが「秒」です
time_t の値を 60 で割ってさらに 60 で割った余りが「分」になります
time_t の値を 60*60 で割ってさらに 24 で割った余りが「時」ですね
time_t の値を 60*60*24 で割った商は基点日からの通日(つうじつ)になります
基点日の 1970-1-1 は木曜日なので、この通日に 4 を足して 7 で割った余りを求めると「曜日」になります
年月日は閏年がある関係で少し面倒なのですが、この通日に ((基準年-1)*365)+((基準年-1)/4)-((基準年-1)/100)+((基準年-1)/400) を足して 365 で割ると、商が「仮の年」に、余りは「仮の年初からの通日」になります
「仮の年」までの閏年の数 ((「仮の年」-1)/4)-((「仮の年」-1)/100)+((「仮の年」-1)/400) を「仮の年初からの通日」から引きます
ここで「仮の年初からの通日」が負になってしまった場合には「仮の年」を前の年にして、新しい「仮の年」の日数(356日か366日)を「仮の年初からの通日」に足します
こうすると「年」と「年初からの通日」が確定します
「年」と「年初からの通日」が確定すれば「月」と「日」は自明ですからこれで年月日時分秒が確定しました
#define leapdays( year ) (((year)/4) - ((year)/100) + ((year)/400)) int isleap( int year ){ int _isleap = 0; if( year % 4 ){ ; } else if( year % 100 ){ _isleap = 1; } else if( year % 400 ){ ; } else{ _isleap = 1; } return _isleap; } static const int c_days_in_year[] = { 365, 366 }; static const int c_days_in_month[][12] = { { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }, { 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }, }; struct tm* my_gmtime( const time_t* pt ){ static struct tm s_tm = {}; time_t t = *pt; s_tm.tm_sec = ( int )( t % 60 ); t /= 60; s_tm.tm_min = ( int )( t % 60 ); t /= 60; s_tm.tm_hour = ( int )( t % 24 ); t /= 24; s_tm.tm_wday = ( int )(( t + 4 ) % 7 ); int epoch_1970_1_1 = ((1970-1)*365) + leapdays( 1970-1 ); t += epoch_1970_1_1; int year = ( int )( t / 365 ); int yday = ( int )( t % 365 ) - leapdays( year ); while( yday < 0 ){ --year; yday += c_days_in_year[isleap( year + 1 )]; } ++year; s_tm.tm_year = year - 1900; s_tm.tm_yday = yday; int mday = yday + 1; int month = 0; int* p = c_days_in_month[isleap( year )]; for( int m = 0; m < 12; ++m ){ int d = p[m]; if( mday <= d ){ break; } mday -= d; ++month; } s_tm.tm_mon = month; s_tm.tm_mday = mday; return &s_tm; }
曜日
上記のコードに曜日を求めている部分がありました
s_tm.tm_wday = ( int )(( t + 4 ) % 7 );
曜日と言うのは 7日周期で同じ曜日になります
この 7日の間に月が変わろうと年末年始が入ろうと閏年が入ろうと一切何も関係ありませんね
日曜日の 1日後の曜日は必ず月曜日ですし、月曜日の 7日後の曜日は必ず月曜日で他には何の前提条件も必要ありませんよね
つまり、予め曜日のわかっている基準となる日から何日経過したかということさえわかればそれが何年前だろうと何年後だろうと必ずその日が何曜日か判るというわけです
ですから、例えば time_t の基点日の 1970-1-1 は木曜日なので、この日から何日経過したかという日数さえわかれば、その日数に 4 を足して 7 で割った余りを求めると「曜日値」が求められ、その日の曜日が判明します
日曜日が 0、月曜日が 1、火曜日が 2、水曜日が 3、木曜日が 4、金曜日が 5、土曜日が 6 です
閏年
また上記のコードに、指定された年が閏年かどうかを判定する関数がありました
int isleap( int year ){ int _isleap = 0; if( year % 4 ){ ; } else if( year % 100 ){ _isleap = 1; } else if( year % 400 ){ ; } else{ _isleap = 1; } return _isleap; }
このコードをよくある説明では「4年で割り切れる年は閏年、ただし 100で割り切れる年は閏年ではない、ただし 100で割り切れても 400で割り切れる年は閏年」やもう少しマシなものでは「閏年は 4年に一回、ただし 100年に一回は閏年ではない、ただし 400年に一回は閏年」などとしていますが、しかしこれはコードの内容をただ日本語にしただけで何の説明にもなっていませんよね
閏年のアルゴリズムを日本語にするなら「閏年は 4年に一回の 4 で割り切れる年とする、ただし毎世紀の最後の年は閏年にはしない。ただし 4世紀に一度は世紀の最後の年であっても閏年とする」とでもした方がいいのではないでしょうか
コードにしやすい説明と意味の説明は異なります
ドキュメントやコメントを書くときには気をつけましょう
説明はともかく、この計算だと閏年は 400年間に 97回となり、一年の平均日数は ( 365*400 + 97 ) / 400 = 365.2425日となります
1900年1月1日で平均太陽年は 365.24219878日とされていますから、そのズレは一年に 27秒弱となっています
27秒弱というズレは凡そ 3200年で一日のズレになります
グレゴリオ暦
このような閏年の取り決めができたのは 16世紀後半のことです
当時の教皇の名からこれをグレゴリオ暦と言います
現在普通に使っている西暦はこのグレゴリオ暦です
このグレゴリオ暦では、それ以前のユリウス暦から閏年のルールを変更すると共に、ユリウス暦で生じていた暦と実際の季節とのズレを解消する目的で暦から 10日間を削除することもしています
この 10日間の削除は通常1582年10月4日(木曜日)の翌日を10月15日(金曜日)とするとされますが、実はこれは国によってまちまちです
世界各国の事情に通じているわけではないので詳しいことは知りませんが、10日間の削除の実施がいつであるにしても、どこかの 10日間が削除されていることには変わりはないと思っていいでしょう
グレゴリオ暦は、閏年の複雑さや、この 10日間の削除による日付の連続性の問題があるために、リニアな日時値にはあまり適していません
20世紀や 21世紀だけを扱えればいいというような場合には SYSTEMTIME や tm などでグレゴリオ暦をそのまま使いますが、それよりもっと長い連続性が必要な日時を扱う計算においては、グレゴリオ暦以前に使われていたユリウス暦を使うことが一般的となっています
ユリウス暦
ユリウス暦はグレゴリオ暦以前に使われていた暦で、この暦ができたのは紀元前一世紀のことです
ユリウス暦にも閏年はありますが、単純に 4年に一度です
閏年が 4年に一度なのでユリウス暦の一年の平均日数は ( 365*4 + 1 ) / 4 = 365.25日になります
上記の平均太陽年の日数と比べると、ユリウス暦では一年に 11分14秒余りもズレてしまうことになります
このズレは僅か 128年程で一日のズレになってしまうほど大きいものです
16世紀半ばの時点でユリウス暦ができてから既に 1600年以上も経っていたのですから、グレゴリオ暦になるときに暦から 10日間が削除された理由はここにあります
しかし、この単純さゆえにユリウス暦は計算に使う日時値としては適しています
太陽年
グレゴリオ暦やユリウス暦では一年を 365.2425日や 365.25日と定めていますが、実際の地球の公転周期は暦の一年よりも少し短くなっています
この地球の公転周期のことを太陽年あるいは回帰年と言います
この現実の太陽年と暦の上の一年とが正確に一致しないために、どうしても暦と現実の季節との間にズレが生じてしまいます
この値は実在の天体の運動なので本当には観測によるしかないわけですが、近似値を得る計算式がいくつか知られています
// 平均回帰年(平均太陽年) inline double get_days_of_mean_solar_year( double year ){ static const double days_of_solar_year_1900_1_1 = 365.24219878; double days_of_solar_year = days_of_solar_year_1900_1_1 - 6.14e-6 * (( year - 1900 ) / 100 ); return days_of_solar_year; } // 春分回帰年 inline double get_days_of_equinoctial_year( double year ){ static const double days_of_equinoctial_year_1900_1_1 = 365.24237404; double days_of_equinoctial_year = days_of_equinoctial_year_1900_1_1 + 1.0338e-7 * (( year - 1900 ) / 100 ); return days_of_equinoctial_year; }
ユリウス通日
計算にユリウス暦を使うと言っても年月日時分秒のままでは扱いにくいのでリニアな日時値にします
このとき基点日時を紀元前4713年1月1日正午としたユリウス通日というものをよく使います
このユリウス通日は基点日時が紀元前4713年1月1日正午の実数型で、1日は 1.0、1秒は 1/86400 です
基点時刻が 0:00:00 ではなく 12:00:00 であることに気をつけてください
例えばユリウス通日を FILETIME に変換するのはこんな感じ:
#define TDAY_NEW_YEARS_DAY( year ) ((((year)-1)*365)+(((year)-1)/4)-(((year)-1)/100)+(((year)-1)/400)+(1582/100)-(1582/400)-10) #define JDAY_NEW_YEARS_DAY( year ) (( double )TDAY_NEW_YEARS_DAY( year )+1721423.5) inline FILETIME JulianDaysToFileTime( double jd ){ ULONGLONG ullDateTime = ( ULONGLONG )(( jd - JDAY_NEW_YEARS_DAY( 1601 ) + 0.5 ) * 24.0 * 60.0 * 60.0 * 1000.0 * 1000.0 * 10.0 ); FILETIME ft = { ( DWORD )ullDateTime, ( DWORD )( ullDateTime >> 32 ) }; return ft; }
修正ユリウス通日
またユリウス通日と同じ時間解像度で、基点日時を 1858-11-17 00:00:00 に置いた修正ユリウス通日というものもよく使われています
これは、19世紀半ば〜21世紀半ばまでの期間はユリウス通日の値が 240万台なのでこの「240万」を省略し、ついでに基点を正午から 0時に直しただけのものですが、無駄に大きな値を扱わなくても済むようになるので広く使われています
修正ユリウス通日は(ユリウス通日-2400000.5)です
修正ユリウス通日はユリウス通日から 2400000.5 を除くことだけが目的なので、基点日時の 1858-11-17 00:00:00 という日時自体には何の意味もありません
十干十二支
十干十二支(じっかんじゅうにし)は干支(えと)ともいいますが、これはある基準日、基準年の干支が判っていれば、そこから一意に求めることができます
曜日と同じ仕組みです
西暦元年が「辛酉」(かのととり)であることから、西暦の「年」-1 に 57 を足して 60 で割った余りを求めると、その年の干支になります
2009年は ( 2009-1 + 57 ) % 60 = 25 なので「己丑」(つちのとうし)の丑年になります
あるいはどこかの「甲子」(きのえね)の年を西暦の「年」から引いて 60 で割った余りを求めるのでも同じです
例えば西暦 4 年が「甲子」なので、西暦の「年」から 4 を引けば、2009年は ( 2009 - 4 ) % 60 = 25 となり結果は同じです
ユリウス通日の基点日である紀元前4713年1月1日は「癸丑」(みずのとうし)なのでユリウス通日に 49 を足して 60 で割った余りを求めると、その日の干支になります
これをさらに 10 で割った余りを求めればその日の十干、12 で割った余りを求めればその日の十二支になります
2009年7月19日は ( 2455031.5 + 0.5 + 49 ) % 60 = 13 なので「乙丑」(きのとうし)、さらにこの時期は土用なので、2009年7月19日は土用の丑の日になります
2009年は 7月31日も ( 2455043.5 + 0.5 + 49 ) % 60 = 1 で「丁丑」(ひのとうし)なのでこちらも土用の丑の日(二の丑)になります
二十四節気と太陽黄経
ある日が「土用の丑の日」かどうかというのを判定するには「ある日が丑の日かどうか」そして「その日が土用の期間内かどうか」ということを判定する必要があります
「ある日が丑の日かどうか」を判定する方法は既に説明しました
では「ある日が土用の期間内かどうか」についてはどうすればいいでしょうか
「ある時期が土用かどうか」というのを判定するには、指定した日付の太陽黄経(こうけい)というものを知る必要があります
太陽黄経というのは、太陽が黄道(こうどう)上のどこにいるか表す角度で、春分を 0度として、夏至を 90度、秋分を 180度、冬至を 270度とするものです
地球は太陽の周りを回っていますが、見た目上は太陽が地球の周りを回っていると見ることもできますから、地球を中心としたときの天球上の太陽の通り道を黄道と言い、この黄道上の太陽の位置を太陽黄経で表します
土用はこの太陽黄経で、立春(315度)、立夏(45度)、立秋(135度)、立冬(225度)の前 18度の範囲と決められています
土用以外にも二十四節気や雑節は太陽黄経に基づいて決められているので、これらを知るには太陽黄経が必要です
二十四節気や雑節などは馴染み深いものなので、それを計算で求めることなど簡単なような気もするかもしれませんが、この太陽黄経の計算というのは実に面倒ですし、太陽黄経から日時を特定するのはさらに面倒です
国立天文台 - 暦計算室 - こよみ用語解説 - 二十四節気
→ http://www.nao.ac.jp/koyomi/faq/24sekki.html
海上保安庁 - 天文情報 - 二十四節気
→ http://www1.kaiho.mlit.go.jp/KOHO/reki/24sekki.htm
計算の仕方は「天測暦」や「天文航法」などといったキーワードのある本に載っているので興味のある人は調べてみましょう
mktime を実装してみる
time_t my_mktime( const struct tm* ptm ){ time_t t = ( ptm->tm_year - 70 ) * 365 + ptm->tm_yday + leapdays( ptm->tm_year + 1900 - 1 ) - leapdays( 1970-1 ); t *= 24; t += ptm->tm_hour; t *= 60; t += ptm->tm_min; t *= 60; t += ptm->tm_sec; return t; }
SystemTimeToFileTime を実装してみる
void MySystemTimeToFileTime( CONST SYSTEMTIME* pst, LPFILETIME pft ){ const int* pm = c_days_in_month[isleap(( int )pst->wYear )]; int yday = 0; for( int m = 0; m < ( int )pst->wMonth-1; ++m ){ yday += pm[m]; } yday += ( int )pst->wDay-1; ULONGLONG t = ( pst->wYear - 1601 ) * 365 + yday + leapdays( pst->wYear - 1 ) - leapdays( 1601-1 ); t *= 24; t += pst->wHour; t *= 60; t += pst->wMinute; t *= 60; t += pst->wSecond; t *= 1000; t += pst->wMilliseconds; t *= 1000*10; pft->dwHighDateTime = ( DWORD )( t >> 32 ); pft->dwLowDateTime = ( DWORD )t; }
用語
- 基点日時
Epoch - 閏年
leap year - グレゴリオ暦
Gregorian calendar - ユリウス暦
Julian calendar - 太陽年
solar year / tropical year - 平均回帰年(平均太陽年)
mean solar year - 春分回帰年
equinoctial year - 春分
vernal equinox - ユリウス通日
Julian Date - 修正ユリウス通日
Modified Julian Date - 十干
ten heavenly stems / ten celestial stems - 十二支
twelve earthly branches - 二十四節気
twenty four solar terms - 太陽黄経
ecliptic longitude - 力学時
ephemeris time