閏年と戦おう

UNIX形式で日付を扱おうとすると、1970年〜2038年という範囲の狭さが問題になることがあります。早い話、今生きている人の生年月日を保持できない。生年月日を保持するのにUNIX形式を使うことないじゃないかと言えばそうなんだけど、システム内で時間なら時間の表現が統一されていないとそれはそれで不便だから。
そこで、1000年1月1日〜9999年12月31日まで扱える日付クラスを作ってみました。あ、PHPね。この範囲は、SQLの時刻型が扱える範囲と一致しています。SQLと相性のいい日付データなのです。
内部的には1000年1月1日を0日目としての通し日数で保持するんだけど、日付からインスタンスを生成したり、日付・曜日を配列で返したり、SQL形式の文字列で返したりします。
もちろん、n日進ませたり遅らせたりする(progressDay,retrogressDay)、nヶ月進めたり遅らせたりする(progressMonth,retrogressMonth)、n年進めたり遅らせたりする(progressYear,retrogressYear)とか、他の日付との日数差を求める(period)なんてメソッドもちゃんと装備してます。
こういうの作ってるとやっぱり閏年って不便ですね。地球が太陽の周りを256.0000日ぐらいで回ってればなんの問題もなかったのに……!

//////////////////////// 1000年1月1日から9999年12月31日まで扱える日付クラス
/////////////////////// DBDate ver.1 by Sampo 2006-10-09
/////////////////////// http://d.hatena.ne.jp/Sampo/20061009
class DBDate{
	var $serial;

	// 月を正常化
	function normalizeMonth( $year, $month ){
		while( $month>12 ){
			$year++;
			$month -=12;
		}
		while( $month<1 ){
			$year --;
			$month +=12;
		}
		return array($year,$month);
	}
	
	//日を正常化
	function normalizeDay( $year, $month, $day ){
		// この時点で月は正常化されているのが前提
		$daysInMonth = array(
			0 => array(1=>31,28,31,30,31,30,31,31,30,31,30,31),
			1 => array(1=>31,29,31,30,31,30,31,31,30,31,30,31)
		);
		while( $day>= $daysInMonth[DBDate::isLeap($year)][$month] ){
			$day -= $daysInMonth[DBDate::isLeap($year)][$month];
			list($year,$month) = DBDate::normalizeMonth( $year, $month +1 );
		}
		while( $day<0 ){
			list($year,$month) = DBDate::normalizeMonth( $year, $month -1 );
			$day += $daysInMonth[DBDate::isLeap($year)][$month];
		}
		return array($year,$month,$day);
	}
	
	//閏年?
	function isLeap($year){
		return
			$year%4!=0 ? 0 : ( 
				$year%100!=0 ? 1 : (
					$year%400!=0 ? 0 : 1
				) 
			);
	}
	
	// コンストラクタ。中身は丸投げ。
	function DBDate($year=1000, $month=1, $day=1 ){
		DBDate::setDate($year,$month,$day);
	}
	
	// コンストラクタの丸投げ先。日付からシリアル値を求めてセット。
	function setDate($year,$month,$day){
		$daysAfterMonth = array(
			0 => array(1=>0,31,59,90,120,151,181,212,243,273,304,334),
			1 => array(1=>0,31,60,91,121,152,182,213,244,274,305,335)
		);
		// 日付を正規化する
		list($year,$month) = DBDate::normalizeMonth( $year, $month );
		list($year,$month,$day) = DBDate::normalizeDay( $year, $month, $day );
			
		$serial=$day - 1 + $daysAfterMonth[DBDate::isLeap($year)][$month];
		$year -= 1000; // 1000年が起点
		$serial += floor( $year / 400) * 146097 ; // 400年
		$year %= 400;
		$serial += floor( $year / 4 ) * 1461 ;    // 4年
		if( $year>0   ) $serial--; //100年に一度の平年
		if( $year>100 ) $serial--; //100年に一度の平年
		if( $year>300 ) $serial--; //100年に一度の平年
		$year %= 4;
		if( $year>0 ) $serial += $year * 365 +1 ;
		
		$this->serial = $serial;
	}
	
	/// n日後
	function progressDay($d=1 ){
		$this->serial += $d;
	}
	
	/// n日前
	function retrogressDay ($d=1 ){
		$this->serial -= $d;
	}
	
	/// 何日後に当たるか?
	function period( $date ){
		return $date->serial - $this->serial;
	}
	
	/// n ヶ月後
	function progressMonth( $m ){
		list($year,$month,$day) = $this->ymd();
		list($year,$month) = DBDate::normalizeMonth( $year, $month+$m );
		list($year,$month,$day) = DBDate::normalizeDay( $year, $month, $day );
		DBDate::setDate($year,$month,$day);
	}
	
	/// n ヶ月前
	function retrogressMonth( $m ){
		list($year,$month,$day) = $this->ymd();
		list($year,$month) = DBDate::normalizeMonth( $year, $month - $m );
		list($year,$month,$day) = DBDate::normalizeDay( $year, $month, $day );
		DBDate::setDate($year,$month,$day);
	}
	
	/// n年後
	function progressYear( $y ){
		list($year,$month,$day) = $this->ymd();
		DBDate::setDate($year + $y,$month,$day);
	}

	/// n年前
	function retrogressYear( $y ){
		list($year,$month,$day) = $this->ymd();
		DBDate::setDate($year + $y,$month,$day);
	}

	// 日付、曜日(0=月曜)を得る。
	function ymd(){
		$daysAfterMonth = array(
			0 => array(1=>0,31,59,90,120,151,181,212,243,273,304,334),
			1 => array(1=>0,31,60,91,121,152,182,213,244,274,305,335)
		);

		$year = 1000;
		$year += floor( $this->serial / 146097 ) *400;
		$mod = $this->serial % 146097;
		if( $mod >= 36524 ){ // 1800年代の分。
			$year += 100;
			$mod -= 36524;
		}
		if( $mod >= 36524 ){ // 1900年代の分。
			$year += 100;
			$mod -= 36524;
		}
		if( $mod >= 36525 ){ // 2000年代の分。
			$year += 100;
			$mod -= 36525;
		}
		while( $mod >= (  DBDate::isLeap($year)?366:365) ){
			$mod -= (  DBDate::isLeap($year)?366:365);
			$year++;
		}
		$isLeap = DBDate::isLeap($year);
		for( $month=12; $mod < $daysAfterMonth[$isLeap][$month] ; $month -- );
		$day = $mod - $daysAfterMonth[$isLeap][$month] +1;
		
		return array($year,$month,$day,($this->serial+2)%7);
	}
	
	// SQLに適した形式で出力
	function sqlDate(){
		list($year,$month,$day)=$this->ymd();
		return sprintf("%04d-%02d-%02d",$year,$month,$day);
	}
}