記事書いて思ったこと
- コードリーディングのポイントを考えて整理してみて、実際に読んでみた
- ライブラリのソースコードを読んでみたら、意外と面白かった
- 日頃から効率よく読む意識をするだけでも多分役に立つ
例題
良いお題が思いつかなかったので、とりあえず13日の金曜日を判定する関数を題材にします。
適当に実装してみました。
from datetime import date
def is_13th_friday(year: int, month: int, day: int) -> bool:
if day != 13:
return False
if date(year, month, day).strftime("%a") == "Fri":
return True
return False
一応判定できますけど、なんだかモヤモヤする実装ですね。
「曜日が判定したいのに、日付文字列の曜日部分を生成して判定してる」のすごいモヤモヤしますね。
コードリーディングのときにおさえておきたいポイント
- 目的をハッキリさせてから読む
- 先を予測しながら読む
- 前提知識を揃えて読む
目的をハッキリさせてから読む
13日の金曜日をいい感じに判定するため、今回コードリーディングする目的をいくつか考えてみます。
-
date.strftime
を用いて判定する方針で良いのか? - どうやって正しい日付が計算されているのか?
このあたりに関心があるので時間があれば上記を頭の片隅に置き、コードを読み始めたり準備をしたりしましょう。
先を予測しながら読む
では、まずは最初の目的
-
date.strftime
を用いて曜日判定する方針で良いのか?
を判断するためライブラリでの実装を確認していきましょう。
おもむろにcpythonのSearchにstrftime
を入れて検索します。
結構いろんなファイルが検索候補にヒットしますが、 実際に知りたい実装がどこにあるのかは読んだことないので知らないはずです。
片っ端から開いてみてもいいのですが、ここは予測しながら読む意味ありそうなファイルを探っていきましょう。
Doc/*
やtest_*
などはドキュメントやテストコードっぽいから実装はなさそうだなーという予測で後回しにします。
(全然見当つかなさそうだったら、テストコードの内容から参照を手繰ってくのはありだと思います。)
で、ファイルパスをそれぞれ確認していくとLib/datetime.py
が近そうな雰囲気なのでこれから読んでいきます。
実際に呼び出されるメソッドはこの部分なので予想は当たってそうな感じがしてきました。
どうやら実質的には _wrap_strftime
を呼び出すだけみたいですが、この関数もフォーマット整えたら_time.strftimeを返すだけの関数のようです。
なので次は_time.strftime
の実装を調べていきます。
import time as _time
がモジュール内に定義されてるので_time
はtime
モジュールの単なるエイリアスですね、なので次はtime
モジュールの実装を探していきます。
ここでLib
ディレクトリに time.py
みたいなモジュールが見つからなかったので、他のディレクトリを探す必要あることに気がつきます。
「Lib
にないならModules
あたりかな?」と予測してファイルを眺めてるとありました、timemodule.cっぽいですね!
ざっと眺めて注目したのはtime_methods
の定義です。
strftime
メソッドを読んだ時にtime_strftime
関数を呼ぶようにマッピングされるよう書いてある気がするので(筆者はC言語初心者なので全然読めません!!!)time_strftime
を見てみます。
関数の返り値はreturn ret
か`return NULL
なので意味ありそうな値を返してるret
が実際にどのような値を持っているか見てみましょう。
ret = PyUnicode_DecodeLocaleAndSize(outbuf, buflen, "surrogateescape");
outbuf
に値が入っててbuflen
がoutbuf
のサイズかな、ということで次はこの変数を使ってそうな部分を探します。
#define format_time strftime
...(中略)...
buflen = format_time(outbuf, i, fmt, &buf);
これっぽいですね、man strftimeを確認してみます。
#include <time.h>
size_t strftime(char *s, size_t max, const char *format,
const struct tm *tm);
だそうなので、libcあたりに実装がありそうということが予想できます。
かなり深いところまで実装を追ってきましたが、これ以上追いかけようとするとlibcあたりの実装を紐解く必要がありそうです。
これ以上追っても処理系がどのようにビルドされてるのかやtime.h
がどこでincludeされてるかちゃんと確認してから調べないと無駄になるかもなので、一旦ここまでで納得したことにします。
(configure読めばなんとなく分かりそう)
で、そろそろ元々の目的を思い出したいと思います。
date.strftime
を用いて曜日判定する方針で良いのか?
曜日が知りたいだけの人にとっては、結果を得るまでに余計な処理をたくさんやってた気がします。
コードリーディングでざっと眺めたとき使えそうな部品見掛けたと思うので、ライブラリ実装使えばより簡単になりそうな気がします。
前提知識を揃えて読む
問題領域に対する知識が不足している場合は、前提知識を整理するところから入るとコードを読む際に助けとなるはずです。
今回必要なことは曜日の概念ですので、暦についての前提知識をまとめておきました。
易しめのお題を用意したので前提知識を揃えるのをサボって読みながら調べてしまったのですが、そういうスタイルでやるのも良いんじゃないかなとも思います。
グレゴリオ暦
詳しい説明はWikipedia読みました。
結構面白かったので、時間があるときに読むのオススメです。
かなり雑に表現すると、「1年を365日に固定すると太陽の回帰周期がだいたい365.24日だからズレが累積するよ、カレンダーのほうでいい感じにしてね」となります。
この誤差のままだと4年で1日近くズレてしまうので4年に1回、閏日を挿入したのユリウス暦になります。
ユリウス暦の導入によって1年の平均日数が365.25日になりましたが、まだ誤差としては結構大きいので数百年で一日以上の誤差が生まれてしまいます。
そこで「年数が、100で割り切れるが400では割り切れない年は、平年とする。これ以外の年では、西暦年数が4で割り切れる年は閏年とする。」というグレゴリオ暦が考案されました。
グレゴリオ暦によって1年の平均日数が365.2425日となり3000年くらい経過しないと1日分のズレが発生しないみたいですね。(このあたりの暦法を考案した人達すごいなあ。。。)
あと今回重要な関心事として、「1582年10月15日を金曜日とする」という記載もありました!そもそもの曜日の基点がどこなのか分からないと本当に正しい曜日かどうか分からないですからね!
どうやって正しい曜日が計算されているのか?
本題の正しい曜日を計算する方針ですが、上記の閏年のルールと曜日の起点が分かったので「1582/10/15からの経過日数を7で割った余り」で計算できそうです。
で、それっぽい実装をライブラリ内で探してみます。
weekdayがそのままの実装でした。
toordianl
の処理が経過日数になるようです、docstringみた感じ1年1月1日を1とした日数のようです。
datetime内部で1年1月1日を基準に扱うみたいなので、この日の曜日が知りたいですね。
「1582年10月15日を金曜日とする」の定義をordinalで書き直すと「577736日を金曜日とする」なので577736 % 7
を計算すると5
になります。
つまり1年1月5日も金曜日なので、1年1月1日は月曜日ということが分かりました!なんとなくキリが良くて嬉しいですね!
weekday
の中にあった謎の+6
は月曜日=0, 日曜日=6
に補正するためのマジックナンバーだったのでしょう。
あとはtoordianl
が正しく計算できているかを確認する過程が残っています。
このメソッドは_ymd2ord
をラップしてるだけなので実処理の方を見てみます。
(一部抜粋)
def _is_leap(year):
"year -> 1 if leap year, else 0."
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
def _days_before_year(year):
"year -> number of days before January 1st of year."
y = year - 1
return y*365 + y//4 - y//100 + y//400
def _days_in_month(year, month):
"year, month -> number of days in that month in that year."
assert 1 <= month <= 12, month
if month == 2 and _is_leap(year):
return 29
return _DAYS_IN_MONTH[month]
def _days_before_month(year, month):
"year, month -> number of days in year preceding first day of month."
assert 1 <= month <= 12, 'month must be in 1..12'
return _DAYS_BEFORE_MONTH[month] + (month > 2 and _is_leap(year))
def _ymd2ord(year, month, day):
"year, month, day -> ordinal, considering 01-Jan-0001 as day 1."
assert 1 <= month <= 12, 'month must be in 1..12'
dim = _days_in_month(year, month)
assert 1 <= day <= dim, ('day must be in 1..%d' % dim)
return (_days_before_year(year) +
_days_before_month(year, month) +
day)
閏年を考慮しつつシンプルな式で計算しており、洗練された読みやすいコードだと思いました。
これで正しく曜日計算できていることが確認できたと思います。
例題を直す
weekdayがやりたいことに合ったメソッドと確認できたのでちょっと書き直してみます。
import calendar
from datetime import date
def is_13th_friday(year: int, month: int, day: int) -> bool:
return day == 13 and date(year, month, day).weekday() == calendar.FRIDAY
コードリーディングしながらより良い実装を探したため随分遠回りしましたが、これで無事(?)13日の金曜日の判定をいい感じにできたと思います。
実行速度
一応%timeit
で測ってみました。
root@localhost:/example# ipython
Python 3.6.9 (default, Nov 7 2019, 10:44:02)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.10.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: from datetime import date
In [2]: dt = date(2019, 12, 13)
In [3]: %timeit dt.strftime("%a")
2.14 us +- 21 ns per loop (mean +- std. dev. of 7 runs, 100000 loops each)
In [4]: %timeit dt.weekday()
47.1 ns +- 0.192 ns per loop (mean +- std. dev. of 7 runs, 10000000 loops each)
In [5]: import calendar
In [6]: %timeit calendar.weekday(2019, 12, 13)
439 ns +- 1.65 ns per loop (mean +- std. dev. of 7 runs, 1000000 loops each)
strftime
遅いのは仕方ないとして、calendar.weekday
がdate.weekday
の10倍遅かったです。
calendar.pyをみると、date.weekday
をラップしてるから仕方ないかなと思いました。
感想
三島由紀夫は「辞書は読むものだ。必要になってから引いてるようじゃだめだ」と言っていました。
そこまでストイックな姿勢を取るのは難しいとは思いますが、コードも読めば読むほどに経験へと繋がると思うので心掛けとして見習うべき部分があると思います。
コードリーディングも少しずつ習慣として取り入れたいところです。