本文主要針對一些簡單的時間議題做探討,後半段會提及實際工作常問到的 Q & A : 時間切割問題。
時間議題 在不同工作相信會有不同議題產生,下面大多是筆者之前接案時遇過的問題 (問題要詳述會花時間,此處不提) ,
當時還傻傻的受限於 MFC::CDate、MFC::CTime (當時沒有 MFC::CDateTime) 功能不足,
自己慢慢擴充,直到後來翻了 Standard Library 後,才知道怎麼做是可接受的方式。
話說,時間切割問題筆者也很常在其他論壇看到,不過還真沒看過有人提出轉換方式。
0. 別期待與時間高度相關之專案,具有可攜性 / time.h 概述
標準函式庫裡面的時間函式相信各位網友都知道,去 time.h / ctime 裡面查就有了,但基本上與「時間」有較高要求之處理,很難單純要求標準函式庫幫忙做好,於是大多還必須再依賴於開發 / 測試環境,及編譯器支援。
舉一個例子:目前標準函式庫裡,找不到「設定目前系統時間」之函式 ( 我翻遍了 c++ reference ,應該是找不到吧?),沒靠編譯器支援,大概就是用 command / script 完成,Win32 底下有支 API 叫 SetSystemTime 可協助。再舉一個例子:取得檔案三種時間 (Access、Create、Modify),這也是沒標準函式支援。
即使如此,但大多 APIs 設計時間/日期函式,概念上均與 time.h / ctime 之時間函式相仿,故說明時還是以標準函式庫做為說明。
1. UTC / GTM / LOCAL TIME
在 ctime / time.h 裡,特別注意到一些敘述指的是 UTC / GTM 還是 Local Time,UTC 指的是 Universal Time Coordinated,全球統一時間。GTM 為格林威治時間,UTC 與 GTM 是完全一樣的。
而所謂的 Local Time,在安裝作業系統時就會有所提示,使用欲顯示時間的時區 (或者是 BIOS 裡面也可調吧),目前台灣一般設定,是 GTM + 8hrs,對岸也是這組設定 (其實講 "設定" 是不好、不對的說法,不過這裡略過長長的說明 )。所以在網路上看別人用中文討論時,會強調 "某兩個函式" 取出來的時間會差八小時,原因是一個是 UTC / GTM ,一個是 Local Time,只是剛好探討的 Local Time 比 GTM 多了 8 小時而已。
2. time.h 概述
另外往下看之前,希望能到 c++ reference 把 ctime / time.h 相關函式 view 過一遍。概要如下
(a) time_t
實際上是一個整數 (或無號數)
(b) struct tm
存時間的結構體,注意到標準函式庫裡提到,有些系統會有「潤秒」問題。
tm_year 為年份,從 1900 年計算(1900當 0);tm_mon 為月份,0~11,
tm_wday 是星期幾,0代表星期日,依此類推;
tm_isdst 是日光節省時間 (在夏季的時候,會將時間調早一小時,早一小時起床,早一小時睡覺,節省能源);
tm_yday 是當日為該年之第幾天 (從 0 開始算)
值得注意的是,有份沒經筆者證實的網路文章, tm_dist 必須設負值,方可忽略潤秒之處理 (可能需查 unix 系統文件)
不過有經 C99 文件證實的是,tm_isdst 為正值為零時,會導致 mktime (struct 轉 time_t ) 將因日光節省時間而有所不同,
(c) diff_time
計算時間差為 diff_time,引數為 time_t,傳回兩個時間的差值 (double, 秒)。
(d) time_t <---> struct tm
time_t 轉 struct tm (UTC / GTM) : gmtime ;
time_t 轉 struct tm (Local Time) : localtime ;
struct tm (Local Time) 轉 time_t : mktime;
struct tm (UTC / GTM ) 轉 time_t : 目前沒這東西。
(e) struct tm ---> str
asctime , strftime
(f) time_t ---> str
ctime
3. 關於時間的儲存方式與轉換
不論是不是標準函式庫,時間的儲存方式大多有兩套:一套是結構體,存 年、月、日、時、分、秒、星期、一年之第幾天 等資訊;另一套是用「整數」去儲存,這個整數的意義就要看該函式之說明。目前用整數去儲存之時間格式,普遍性都是以「距離哪天的時間點」為時間差。
舉個例,time_t 實質上是一個整數,代表意義是 距離 UTC (世界標準時間) 1970 年,一月一日,00:00:00 所經過之秒數,這個整數在我的系統裡是被定義成 32 位元有號數 ( __int32 ,另一可能是 __int64,depends on system ),若以 32 位元有號數來講,代表最多可歷經 2147483647 秒,一年以365.25 天來粗算的話,約可表達 68.05 年,換句話說,約到 2038 年時,它會有溢位的可能,至於到 2038 年的哪時會溢位,嗯,我沒興趣再精算了。
不過我想如果快到 2038 年時 (還有25年) ,又如果那時 C/C++ 還沒被淘汰的話,或許到時便會要求至少要 64 bits 整數實作 time_t 。
另一種時間儲存方法是 struct,它主要是顯示一些資訊,提供 coder 使用,電腦真正在計算時,還是以「整數」方式為佳。另外,struct 結構不一定會有,但以整數表示時間的資料型態一定會有。若有 struct 時間結構體時,通常還會再附上一些 (struct 時間結構體 <-----> 整數時間表示) 之轉換函式。
又以 Win32 API 來講,像 FILETIME 本身雖是一個結構體,但本質上是一個整數,因這個結構體存放的是兩個 unsigned,一個 high-word,一個 lo-word,代表從 UTC 1601 年 1 月 1 日 00:00,經過了 幾百個 nano-secs,而相對應轉換的結構體是 SYSTEMTIME ,相換轉換函式包含了FileTimeToSystemTime、SystemTimeToFileTime。 FILETIME 以 64-bits 來算的話,從 1601 年必需再經過約 5.8 萬年以上才可能溢位。
( 2^64-1 ) / 10^7 = 1.844674407 x 10^12 (secs)
1.844674407 x 10^12 (secs) / 3600 = 512409557 (hours)
512409557/ 24 = 21350398 (days)
21350398 / 365.25 = 58454 (years)
4. 潤年問題
這裡不會講曆法,不會講古邏馬,不會講農民曆、不會講原子時與世界時... etc。
從程式邏輯上,廣為人知是 4 年一潤、100 不潤、400 又潤,目前的系統大概是從這規則。事實上一年 (正確的說法是一個迴歸年,tropical year) 是 365.24220 天,去做修正,故另一種說法是, 4 的倍數潤年,100 倍數不潤,400 倍數潤,4000 倍數不潤,由於 4000 倍數離現在實在是差太遠了,所以大概也還沒被納入規則過。所以程式碼大概都是這麼寫的。
int is_leap_year(int year) { return (year%400==0) || ( (year%4==0) && (year%100!=0) ) ; }
這算好算。另一個問題是,給定 [year_begin, year_end],其中有幾個潤年?這問題要寫的好就不容易了。
最無腦的寫法
int leap_year_cnt(int year_beg, int year_end) { int c=0; while(year_beg <= year_end ){ c += is_leap_year(year_beg); ++year_beg; } return c; }
好一點的方式是,先從 year_beg 開始找到 4 的倍數,每次 + 4 進入,再逐一判斷。
筆者的想法如下。
(1) 計算 [1, year_beg] 中,有幾個潤年
int c1 = year_beg / 4 - (year_beg / 100) + (year_beg / 400);
(2) 計算 [1, year_end] 中,有幾個潤年
int c2 = year_end / 4 - (year_end / 100) + (year_end / 400);
(3) c2 - c1 代表 (year_beg, year_end] 中有幾個潤年,故最後再額外判斷 year_beg 是否為潤年。
int leap_year_cnt2(int year_beg, int year_end) { int c1 = year_beg / 4 - (year_beg / 100) + (year_beg / 400); int c2 = year_end / 4 - (year_end / 100) + (year_end / 400); return c2-c1 + is_leap_year(year_beg) ; }
5. 計算一年中的第幾天 / 求一年中的第幾天
(5.1 ) 假設給定 yyyy 年 mm 月 dd 日,要計算該天是當年的第 n 天 (假設 1/1 是第 1 天);
(5.2) 假設給定 yyyy 年的第 n 天,求得當天為 mm 月 dd 日。
關鍵只有一個,建立一個表,裡面放的是累計天數,其它的可順利推出。程式碼不做 error defect,均假設輸入是正常,大致如下。
const int column_days[] = { \ 0, 0, 31, 59, 90, 120, 151, \ 181, 212, 243, 273, 304, 334}; int find_nday(int year, int month, int day) { return column_days[month] + day + \ (month > 2 && is_leap_year(year)); }
這裡有個偷雞的方法可以求,但效率「可能」會差一些 (除非在特殊情況)。首先我們知道 struct tm 可以轉成 time_t,最後再用 time_t 轉回 struct tim,再取得裡面的 tm_wday 就是了。這個方法除了可以知道是一年中的第幾天之外,還可以順便算算它是星期幾。
#include <stdio.h> #include <time.h> int find_nday(int year, int month, int day) { struct tm src={0}; struct tm *dst ; time_t rawtime; // 設定 struct src.tm_year = year-1900; src.tm_mon = month-1; src.tm_mday = day;
src.tm_isdst = -1 ;
rawtime = mktime(&src); // 轉到 time_t dst = localtime (&rawtime);// 再轉回 struct return dst->tm_wday; return dst->tm_yday+1; } int main() { int y, m, d; while(scanf("%d%d%d", &y,&m,&d)==3) printf("%d\n", find_nday(y, m, d)); return 0; }
有人會問:這裡用的 struct tm 轉成 time_t ,是設定 年、月、日、時、分、秒,其中的 tm_yday (一年中的第幾天) 和 tm_wday ( 星期幾) 都沒用到,說不定 mktime 會吃 tm_yday,而不是吃年月日。
嗯,這作法筆者查過了,C99 7.23.2.3 裡面有強調,用 mktime 將 struct tm (local time) 轉成 time_t 時,tm_wday 及 tm_yday 會被忽略,只用其他的值 (那不就是指年月日時分秒 及 日光節約時 了嗎!) 去轉成相對的 time_t,所以這作法可以放心使用,同時也說明了,它不能設定 tm_yday,去查是哪一天。
要從 ndays 轉成日期概念上也如上面建立陣面概念類似, 只是建立的表不同。code 隨手寫寫,要效率高就要再化簡過
int mon_days[] = { \ 0, 31, 28, 31, 30, 31, \ 30, 31, 31, 30, 31, 30, 31};
void nday_to_date( int year, int ndays, int *month, int *day) { int m ; // temp of month mon_days[2] = 28 + is_leap_year(year); for(m=1 ; ndays>mon_days[m] ; ++m) ndays-=mon_days[m]; if(ndays==0) ndays=mon_days[--m]; *day = ndays, *month=m; }
6. 算星期
別用定義 (公元元年 1 月 1 日是星期日) 去算,一方面已不實用,曆法中間有改過幾次;一方面 loop 跑很慢,所以下面公式也是在 15xx 年 之後適用。
主要有兩個公式,一個是基拉爾森算式,另一個是蔡勒公式 (有改善版)。
基拉爾算式用看的就大概就清楚,主要是蔡勒公式,( 目前網上翻似乎沒用 C 語言寫正確的?),參考如下。
// -------------------------------------------------------- // 基姆拉爾森算式 int weekday(int y, int m, int d) { return (d+2*m+3*(m+1)/5+y+y/4-y/100+y/400)%7; } // -------------------------------------------------------- // Zeller int Zeller(int Y, int m, int d) { int c, y, w; // c : center , y : last two dig of year , w : days of week if(m==1 || m==2) m+=12, --Y; y = Y % 100, c=Y/100; w = y + y/4 + c/4 - 2*c + 26*(m+1)/10+d-1; // negative possible return (w%7+7)%7; } // -------------------------------------------------------- // fix zeller int Zeller2(int Y, int m, int d) { int r[] = {0, 6, 2, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}; int y = Y % 100, c=Y/100; int w; if(is_leap_year(y)) r[1]=5, r[2]=1; //leap_year fix w = y / 4 + y%7+ 2*(c%4) + r[m] + d; return w % 7; }
通常不會直接用上述這些公式求,有些系統用的公式可能有用到浮點數之 bit 運算。
當然還必須強調,可以用 mktime,再轉回 localtime,這樣就知道是星期幾了。
7. 給定兩個日期(年月日),計算相差幾天
有些情況,是要算兩個日期相差幾天,在這問題下,我們先算的是,兩個日期相差幾秒,為何?因為轉成 time_t 可以輕易得到。
int y1, m1, d1; /* 開始年月日 */
int y2, m2, d2; /* 結束年月日 */
(1) y1, m1, d1 填入 struct tm begin。
(2) y2, m2, d2 填入 struct tm end。
(3) time_t raw_begin = mktime(&begin);
(4) time_t raw_end = mktime(&end);
(5) 傳回 (raw_end - raw_begin)
#include <stdio.h> #include <time.h> int diff_secs(int y1, int m1, int d1, int y2, int m2, int d2) { struct tm begin={0,0,0,d1,m1-1,y1-1900}; struct tm end={0,0,0,d2,m2-1,y2-1900}; time_t rawtime1, rawtime2; rawtime1 = mktime(&begin); rawtime2 = mktime(&end); return mktime(&end) - mktime(&begin); return (rawtime2 - rawtime1); } double Sec2Min(int secs) {return secs / 60.0; } double Sec2Hour(int secs) {return secs / 3600.0; } double Sec2Day(int secs) {return secs / 86400.0;} int main() { int y1, y2, m1, m2, d1, d2, Tsecs; while( scanf("%d%d%d%d%d%d",&y1,&m1,&d1,&y2,&m2,&d2)==6){ Tsecs = diff_secs(y1,m1,d1,y2,m2,d2); printf("Td (secs) = %d\n", Tsecs); printf("Td (mins) = %f\n", Sec2Min(Tsecs)); printf("Td (hours) = %f\n", Sec2Hour(Tsecs)); printf("Td (days) = %f\n", Sec2Day(Tsecs)); } return 0; }
其他要改成相差幾天、相差幾小時,用一樣方法計算。
8. 給定兩個時間(年月日時分秒),做 n 等分切割
有上一個概念之後,要做切割也不是問題。
(0) 中間有 n 等份的「時間區間」,故有 n+1 [0, n] 個點,第 0 個點是 begin,第 n 個點是 end, 中間間隔為 (end-begin) / n,另 0 等份是不存在的。
(1) 取得 time_t raw_begin, raw_end
(2) 取得 raw_end - raw_begin,相差秒數
(3) 取得每等份之時間間隔:Td = (double)(raw_end - raw_begin) / n;
(4) 確切間隔之時間點,虛碼大致如下。
time_t t0 = raw_begin; // 一開始的參考點
for(i=1; i<n; ++i) { // 第 i 份區間結束點
time_t ti = (time_t) (raw_begin + i * Td + 0.5);
printf("T%d = %s\n", i, ctime(ti);
}
time_t tn = raw_end; // 第 n 份區間結束點
#include <stdio.h> #include <time.h> int TimeDiv( int y1, int m1, int d1, int h1, int mm1, int s1, int y2, int m2, int d2, int h2, int mm2, int s2, time_t *tpoint, int n) { double Td; int i; struct tm begin={s1,mm1,h1,d1,m1-1,y1-1900,0,0,-1}; struct tm end={s2,mm2,h2,d2,m2-1,y2-1900,0,0,-1}; time_t raw_begin = mktime(&begin); time_t raw_end = mktime(&end); if(n < 1 || raw_begin > raw_end) return 0; // fail Td = \ (double)(mktime(&end) - raw_begin) / n; for(tpoint[0] = raw_begin, i=1; i < n; ++i) tpoint[i] = raw_begin + (time_t)(Td * i+0.5); tpoint[n] = raw_end; return 1; } int main() { int y1, m1, d1, h1, mm1, s1; // 時間1, 年月日時分秒 int y2, m2, d2, h2, mm2, s2; // 時間2, 年月日時分秒 int i, n; // 切成 n 等份 time_t Tdiv[100]={0}; // 切割點, 假設最大100筆 while( scanf("%d%d%d%d%d%d%d%d%d%d%d%d%d", &y1,&m1,&d1,&h1,&mm1,&s1, &y2,&m2,&d2,&h2,&mm2,&s2,&n)==13){ TimeDiv(y1,m1,d1,h1,mm1,s1,y2,m2,d2,h2,mm2,s2,Tdiv,n); // 輸出時間點 for(i=0; i<n+1; ++i) { // ctime 會自動加 new-line printf("%s", ctime(Tdiv+i)); } } return 0; }
相對的,若已知開始時間點 struct tm begin,要遞增 struct tm td ,往後取 n 個,作法與上相似。
9. 合法偵錯
上面程式碼省略了事前正確性判斷 (個人習慣由 caller 端去做),日期、時間輸入是否正確,應再額外判斷,怎麼判斷我想就不贅述了。
10. Others
幾個議題筆者沒切迫性需求沒再研究(其實還不是犯懶),作法很差,有好的作法或參考文章歡迎提供。
(1) 網路系統時間同步 : 這只是單純的練習題,但考慮到網路品質及傳遞時間問題應不是件簡單事,沒深入研究。
(2) 直接由 time_t 求星期幾、該年的第幾天 (不經過 struct tm 轉換):目前沒太多時間再去研究這個,有興趣可再研究。