というのも職場でリファクタリングについて色々聞かれる中で、こういう基礎的な知識と実際の設計コーディングが頭の中で繋がっていないのが、色々な理解についての阻害要因になっていると感じたから書いてみました。
プログラムを始めようと思う方に是非頭の隅に置いておいてほしい内容です。
今回はサンプルソースがメチャクチャ簡単ですが、とても長いので後に回します。
ド・モルガンの定理ってあったよね
高校数学とかでド・モルガンの定理ってやったと思います。
not( a and b ) が not(a) or not(b)
not( a or b ) が not(a) and not(b)
とそれぞれ等価になるっていうあれです。
といっても集合代数を使おうっていう話ではなく、プログラム上の論理式、複雑なif文などの論理的な道筋を、これらを使うと単純に出来る、というお話です。
まずは本当に、
not( a and b ) が not(a) or not(b)
not( a or b ) が not(a) and not(b)
と等しいか確認してみましょう。
void and2or(bool a, bool b) { if( !( a || b ) ) { std::cout << "!(a or b)" << std::endl; } if( !( a && b ) ) { std::cout << "!(a and b)" << std::endl; } if( !a && !b ) { std::cout << "!a and !b" << std::endl; } if( !a || !b ) { std::cout << "!a or !b" << std::endl; } }
ステップ実行などで追いやすいように、それぞれの条件式を作ってみました。
もし
not( a and b ) が not(a) or not(b)
not( a or b ) が not(a) and not(b)
が正しいのであれば、
!a and !b や !a or !b は、
!( a or b ) や !( a and b ) とセットで現れるはずです。
これを呼んでみます。
int main(int argc, char** argv) { std::cout << "a:true b:true" << std::endl; and2or(true, true); std::cout << "a:true b:false" << std::endl; and2or(true, false); std::cout << "a:false b:false" << std::endl; and2or(false, false); return 0; }
実行結果
a:true b:true a:true b:false !(a and b) !a or !b a:false b:false !(a or b) !(a and b) !a and !b !a or !b特にaとbの真偽地が異なる場合が分かりやすいですが、ステップ実行などで確認すると、 論理式の真偽値ド・モルガンの定理通りにペアで揃うことがわかります。
じゃあこれ実際何に使えるんだ?
この考え方を適用すれば、例えば以下のような複合if文で作られた追いにくい処理を、 超★簡単にリファクタリングすることが出来ます。void function_gotcha(uint8_t w, uint8_t x, uint8_t y, uint8_t z, bool a, bool b) { if( ( 100 < w || 50 > w ) || ( x == y && y != z ) && ( a || b ) ) { std::cout << "executed" << std::endl; return; } std::cout << "aborted" << std::endl; return; }いやぁ、見るも悍ましいですね。
ただ悲しいことにこのようなコードは実際にビジネスの場でもよく見ます。
このようなコードが出来上がる理由は、
日本語の仕様書をそのままコードに落としたから、
だとか、
一つにまとまっていたほうが読みやすいから、
だというのが主張らしいです。
真っ向から反論すれば、
自然言語の仕様をそのまま実装に落とすのは単純な考慮不足に当たる行為ですし、
見やすさと可読性は似て非なるもので見た目はコンパクトでも可読性が最悪です。
しかもこのようなコードでは、
条件単体が意図した値で成立するのかデバッガで追えませんし、
テストパターンも組み合わせが膨大になってしまいます。
そこでこのような条件式はできるだけ分解して、テスト可能かつ読みやすい形にしていくのが正しいです。
順を追って分解していきましょう。
第1段階 OR条件でif文を分割
if( 100 < w || 50 > w ) { std::cout << "executed" << std::endl; } if( ( x == y && y != z ) && ( a || b ) ) { std::cout << "executed" << std::endl; }複合if文のOR条件は、単純に同層の別if文に分解することが出来ます。
巨大な複合if文が出てきたときは、
まず条件全体で分解できそうなOR条件で切り出してしまい、
以降の作業を簡単に行えるよう条件式のサイズそのものを細かくするところから着手します。
第2段階 OR条件でif文を分割
if( 100 < w ) { std::cout << "executed" << std::endl; } if( 50 > w ) { std::cout << "executed" << std::endl; } if( ( x == y && y != z ) && ( a || b ) ) { std::cout << "executed" << std::endl; }更に細かくします。
wの値域の見やすさなどを考慮すると分割しないほうが良いかもしれませんが、説明を簡単にするため一旦分けます。
参考:第2.5段階 AND条件はif文の入れ子になってしまう
if( x == y && y != z ) { if( a || b ) { std::cout << "executed" << std::endl; } }(a||b)をわけないのは、この条件が( x == y && y != z )とandで結合しているからです。
(a||b)を分けるにはこのandの結合を分割してから行う必要がありますが、単純に分割してしまうとand条件はif文のネストにあたり、ネスト階層が出来てしまいます。
第3段階 ド・モルガンの定理の適用
if( !( x == y && y != z ) || !( a || b ) ) { //条件式全体を否定するので、その中のコードも真偽逆の実行順になる点に注意 std::cout << "aborted" << std::endl; }ある程度十分に分割できたら、今度はand条件を切り離します。
ですが前述の通りand結合は単純に分割するとネストになるので、ここでいよいよド・モルガンの定理を利用します。
まずド・モルガンの定理に従い条件式全体を否定します。
するとandはorに切り替わり、条件式を分割することが出来る形に変化します。
第4段階 OR条件で分割
if( !( x == y && y != z ) ) { std::cout << "aborted" << std::endl; } if(!( a || b )) { std::cout << "aborted" << std::endl; }そして単純にor分解。
分解した際に条件式成立時に実行されるコードもひっくり返っていることには注意が必要です。
第5段階 またド・モルガン
if( x != y || y == z ) { std::cout << "aborted" << std::endl; } if( !a && !b ) { std::cout << "aborted" << std::endl; }第4段階で分割するために、条件式それぞれを否定しています。
これをさらに複合されていた個別の条件式に適用してやることで、条件式を更に細かく分割できる形になります。
第6段階 さらにド・モルガン
if( x != y || y == z ) { std::cout << "aborted" << std::endl; } if( a || b ) { std::cout << "executed" << std::endl; }注意深く条件式成立時の式の真偽を確認しながら分解していきましょう。
ここでは( !a && !b )を分解する過程で、実行行が入れ替わっています。
このサンプルでは標準出力への出力を目印に使用していますが、実際の作業でもコメントアウトなどを用いて、 慣れないうちは必ずメモを取りながらやると良いでしょう。
第7段階 徹底的に分割
if( x != y ) { std::cout << "aborted" << std::endl; } if( y == z ) { std::cout << "aborted" << std::endl; } if( a ) { std::cout << "executed" << std::endl; } if( b ) { std::cout << "executed" << std::endl; }とにかく徹底的にバラします。
ここまでやる必要があるかと問われればあります。
リファクタリング作業はコードだけの問題ではなく、個別の条件式が実際に設計通りに実装されているか、つまり、設計自体が思い違いのもと書かれていないか確認する作業でもあるからです。
ですから無駄に思えても、とにかく徹底的にバラしましょう。
上手に別けました!
void function_regulated(uint8_t w, uint8_t x, uint8_t y, uint8_t z, bool a, bool b) { if( 100 < w ) { std::cout << "executed" << std::endl; return; } if( 50 > w ) { std::cout << "executed" << std::endl; return; } if( x != y ) { std::cout << "aborted" << std::endl; return; } if( y == z ) { std::cout << "aborted" << std::endl; return; } if( a ) { std::cout << "executed" << std::endl; return; } if( b ) { std::cout << "executed" << std::endl; return; } }完全にバラすとこのような形の処理が出来上がると思います。
そしてネストなしのif文であれば、そのスコープ一つ一つをそのまま他の関数にしてしまうことも可能です。
今回は更に関数分割をしてみます。
まず上から条件式を見ていくと、
wの値域が50未満または100より大きいときに処理を実行したいことがわかります。
なのでこれはw単独の引数を取る関数とします。
次にx,y,zはyを中心に値比較を行っており、この3値が密接に関係していることが伺えます。
なのでこれはx,y,zを引数に取る関数とします。
最後にaとb、これらは単独でも良いのですが、どちらかが立っていれば良いという元の条件の意図を無視せず、 a,bを引数に取る関数とします。
纏め直した結果
void function_w(uint8_t w) { if( w < 50 || 100 < w ) { std::cout << "executed" << std::endl; } return; } void function_x_y_z(uint8_t x, uint8_t y, uint8_t z) { if( x == y ) { if( y ^ z ) { std::cout << "executed" << std::endl; } } return; } void function_a_b(bool a, bool b) { if( a | b ) { std::cout << "executed" << std::endl; } return; }このように分割することが出来ました。
関数それぞれの引数も少なくなりましたので、テストコードを組む際も数パターンずつのテストで単体テストが可能です。
また一度関連を解いた変数を意味論的に纏めなおしているので、コードと仕様との対応の確認もしやすくなります。
さいごに
今回は言語サンプルと言うよりも基礎知識の部分を記事とさせていただきました。私自身今回勤め先で聞かれて初めて、こういうのって説明する必要があるんだと認識したわけですが、よく思い返してみると実際10年この業界で勤めていて、設計や実装をとても巧みにされる方がいる反面、多くの方はこういった基礎知識を置き去りにして業務に入っている現実があることを実感しています。
それに世の中で動くソフトウェアの大半は、基礎知識が十分に浸透する以前に作られたソースコードで動いているのもまた事実です。
もし参考になる方がいらっしゃれば、より高度なリファクタリングの手法を読み解くための足掛かりとして参考にして頂ければありがたく思います。
0 件のコメント:
コメントを投稿