私はある種のパーサーとマッパーを頻繁に書いています。つまり、有効な入力を確認するために、switchステートメントが頻繁に、多くの場合ifステートメントで使用されます。
もちろん、これは多くの循環的複雑さを生み出します。私は通常、値のパーサーを分割します。だから私はいくつかのデータフォーマットを持っており、データをより一般的なまたは内部フォーマットに変換/マッピングする必要があり、多くの場合単位も変換します。そこで、それらを変換する関数またはメソッドを作成します。
私は読みやすさを気にしています。人々が思いつく最良の測定は、循環的な複雑さのようです。
現在、コードを1行ずつ読み取るのはすばらしい方法だと思いますが、マッピングが明確なだけでは少し苦労します。例としては、カーディナル方向(WSWなど)から度(247.5°)への変換があります。関数には単純なswitchステートメントが含まれていますが、その循環的複雑度は非常に高くなっています。これは、この関数が何をするか、どのように機能するかなどを即座に知らない人がいないことにもかかわらずです。したがって、実際にはコードの中で最も読みやすい関数の1つであると主張しますが、リンターはこの関数と同様の関数について文句を言います。
私はこれについてもう少し掘り下げて、他の人が何をしたかを見ました。ここで、私が問題と考えることに気づきました。関数ごとにサイクロマティックの複雑さを測定すると、人々が私が「だましている」と考えるものにつながります。関数は、別の関数で使用した場合にのみ機能する多くの関数に分割されます。
したがって、リンターでスコアを増やしたい開発者は、基本的にコードを途中で分割して両方の関数を呼び出すことにより、コードを「リファクタリング」します。これらの関数はそれぞれ、循環的複雑度が半分になりますが、両方を順番に呼び出す必要があります。したがって、実際には誰でも関数を読み取るのが容易になるわけではなく、分割された(プライベート)関数も、呼び出し元の関数および2番目の関数と相互依存しているため、再利用できません。これにより、複雑さが3つの関数に分散され、報告された複雑さが低下しますが、読みやすくなることはありません。
興味深いサウンド名や2つの関数を続けて呼び出すだけでなく、複数の関数を使用することによって、これらのアプローチがうまく隠されていない場合があります。複数の関数があるため、かなり順序が狂っていて、複雑なコードとスパゲッティコードが構造はリンターによって大丈夫と考えられているが、他の開発者には非常に読みにくい。
私の質問は、それらのものが実際に私が見逃している利点を持っているかどうか、そうでない場合、それがリント時に発生しないようにする方法があるかどうかです。
クラス全体、モジュール全体、プログラム全体で測定する方が理にかなっていますか?
もちろん、これは関数だけでなく、別のクラスと一緒に使用するためにのみ存在するメソッドまたはクラスにも限定されず、開発者は使用する他のクラスを完全に理解する必要があります。
編集:不正行為とは、意味のある個別の機能に何かを分割することではないことを意味します。例を挙げましょう。
func cardinal16ToDegree(dir string) (float64, error) {
switch strings.ToLower(dir) {
case "n":
return 0, nil
case "nne":
return 22.5, nil
case "ne":
return 45, nil
case "ene":
return 67.5, nil
case "e":
return 90, nil
case "ese":
return 112.5, nil
case "se":
return 135, nil
case "sse":
return 157.5, nil
case "s":
return 180, nil
case "ssw":
return 202.5, nil
case "sw":
return 225, nil
case "wsw":
return 247.5, nil
case "w":
return 270, nil
case "wnw":
return 292.5, nil
case "nw":
return 315, nil
case "nnw":
return 337.5, nil
}
return 0, errors.New("could not parse cardinal direction")
}
より複雑ではありません:
func Cardinal16ToDegree(dir string) (float64, error) {
switch dir {
case "n":
return 0, nil
case "nne":
return 22.5, nil
case "ne":
return 45, nil
case "ene":
return 67.5, nil
case "e":
return 90, nil
case "ese":
return 112.5, nil
default:
return cardinalHelper(dir)
}
return 0, errors.New("unreachable")
}
func cardinalHelper(dir string) (float64, error) {
switch dir {
case "se":
return 135, nil
case "sse":
return 157.5, nil
case "s":
return 180, nil
case "ssw":
return 202.5, nil
case "sw":
return 225, nil
case "wsw":
return 247.5, nil
case "w":
return 270, nil
case "wnw":
return 292.5, nil
case "nw":
return 315, nil
case "nnw":
return 337.5, nil
}
return 0, errors.New("could not parse cardinal direction")
}
これは極端なケースです。不正行為とは、ロジックが分割されているがカプセル化されていないため、他の関数と一緒にalwaysがユニットで見られる必要がある関数から何かを取り出すことにより、スコアを下げることを意味します。したがって、それについて推論したい場合、一方を「ブラックボックス」と見なして一方の機能だけを検討する方法や、他方の独立機能を検討する方法はありません。
これは、関数とクラスの間で渡される巨大な状態オブジェクトのような複雑なデータ構造を作成することによって「到達」することも多く、明確なスキーマに従っていません。これはもちろん、フラットでない辞書から追加の任意のキーと値を削除するのが簡単な言語で発生します。これらは、ディクショナリをグローバルオブジェクトとして使用するか、1つの大きな関数のスコープとなるものを使用して、スパゲッティコードを「隠す」ことを意味します。繰り返しますが、ここでは明確に定義されたオブジェクトについては触れませんが、循環的複雑度を下げる方法にすぎません。だから私はそれを浮気と呼んでいます。
それが私が意味することの例はこの形です:
function foo(a, b, c) {
// many lines of code
x = ...
y = ...
z = ...
// more code
result = _foo(a, b, c, x, y, z)
return result
}
function _foo(a, b, c, x, y, z) {
// many lines of code that require
// knowledge of the implementation of
// foo and that are not a logical
// separate step, but an extension
// of foo
return some_result
}
繰り返しになりますが、循環的複雑度はこれらの関数のそれぞれに対して低いと見なされます。
はい、この「不正行為」は現実のものであり、はい、循環的複雑度は主観的なコードの単純さの理想的な尺度ではありません。それは依然として非常に有用なコード品質インジケータです。
関数を抽出するとき、抽象化を導入しています。抽象化には認識のオーバーヘッドがあり、「無料」ではありません。このリファクタリングの技術は、抽象化を慎重に選択することであり、抽出されたコードを理解するよりも抽象化を理解する方が簡単です。これは、(必ずしもそうとは限りませんが)新しい抽象化を使用して複数のコードを簡略化できる場合ですが、十分に強力な場合は、1回限りの抽象化でも役立ちます。
関数を任意に分割しても、値はありません。これは抽象化を作成せず、実際の制御フローを難読化するだけです。 1つの場所で非常に複雑になったものは、今やあらゆる場所で非常に複雑になりました。コードのまとまりが少なくなりました。
単純な関数ベースの循環的複雑度の代わりに、ユニットテストされて一緒に理解されるコードの部分の循環的複雑度を計算する方が理にかなっています。この意味で、抽象化を提供せず、明確に定義されたインターフェースを持たないprivateヘルパー関数を導入することの価値は限られています。ここでの利点は、繰り返しをなくすことだけです。対照的に、個別にテスト、理解、および文書化できるモジュール、クラス、または関数を導入すると、有用な抽象化を表す可能性が高くなります。コードの循環的複雑度の計算にそれらを含めることは役に立ちません。
この種の計算を行うツールは知りません。プロキシとして、テスト対象のシステムのブランチカバレッジを高くすることを目標に、テスト作業を使用します。優れた抽象化を使用してコードを分離すると、多くの労力をかけずに、関心のあるものを直接直接テストできます。簡単にカバーできないコード部分がある場合、これらの部分が抽象化の候補であることを示している可能性があります。これは、入れ子になった制御フロー構造の場合や、複数の無関係な順次条件またはループの場合によく見られます。
最後に、主観的な複雑さは、ツールによって生成される数値よりも重要であることを指摘する価値があります。自動品質チェックは、デザインやネーミングの問題だけでなく、リフレクションやメタプログラミングの使いすぎなど、人間にとってコードを理解しにくくする多くの部分を見落とす可能性があります。そして、ある種のコードは、人間にとって、ツールにとって理解しやすいものです。 switch/caseコンストラクトはこの典型的な例です。人間の場合、20ケースは5ケースの2倍の複雑さで表示されます。ただし、ケースが単純で、相互に影響を与えない場合に限ります。表形式は私たちが理解するのが非常に簡単です!しかし、リンターは20のブランチを参照します。これは、許容可能な循環的複雑度をはるかに超えています。
そのため、小さなコード領域に対して、その価値のあるリンターを選択的にオフにすることができます。リンター警告は調査するための提案にすぎません。警告が誤検知であり、コードが実際に理解しやすいと調査で結論付けられた場合は、その関数をリファクタリングしないでください。
まず第一に、関数は抽象化の基本的な手段です (SICP) は必ずしも再利用の手段ではありません。ソフトウェアエンジニアリングの主な問題は、アプリケーションドメインの複雑さに取り組むことです。関数の複雑さを制限することにより(サイクロマティック、コード行(LOC)の長さ、またはその他の測定値により)、コードの読者に現在の関数を理解するためのより優れた機能を提供します。人間には限られた ワーキングメモリ があるため、複雑さを軽減するとプログラムの保守性と可読性が向上します。
一連のステートメントの代わりに説明的な名前の付いたプロシージャまたは関数を提供することで、コードの読者にメンタルモデルをフックできるより良い手段を提供します。
実用的な観点から、関数のインライン展開は、再利用できない関数の問題に大きく対処します。
もちろんすべてのものと同様に、バランスがあります。残念ながら、そこにある大量のコードは、抽象化が十分に活用されていないため、読みにくくなっています。
Cyclomatic Complexityは、関数の読み取り中に作業メモリに保持する必要がある制御パスの数を示すという点で、可読性の測定に役立ちます。それは完璧ではありませんが、コードの複雑さを解消する優れた方法の1つであり、トレースを容易にするために関数をリファクタリングする必要があることを示しています。
循環的複雑度には、スイッチケースに関する既知の問題があります(代替手段としての認知的複雑度に関するリファレンスを参照してください)。スイッチケースを通る制御パスは巨大になる場合がありますが、パスが取られる条件は簡単に理解できます(スイッチケースが適切に記述されている場合)。コンパイラーはサイズと時間の効率を高めるために(特にC/C++で)最適化することが多いため、スイッチケースは頻繁に使用されます。スイッチケースを使用して、それらの複雑な値にもかかわらず読みやすさを向上させる場合のガイドラインをいくつか次に示します。
複数行の場合、インライン関数(または関数のみ)でケース本体をラップすると、読みやすさとわかりやすさが向上します。何よりもまず、case文で使用されている状態/変数を関数のパラメーターに明確に分離することをプログラマーに強制します。第二に、関数の長さが信じられないほど大きくなるのを防ぎます。 100 LOCを超える関数の長さは、多くの場合、ソースバグであり、リファクタリング/メンテナンス(コード完了)が困難です。
これが不可能な場合は、少なくともスイッチ本体の周りに中括弧を付けて、ケース本体の輪郭を描き、範囲を定めてください。
これは、CERTセキュアコーディング標準規則(CERT DCL41-C)に直接従います。それはかなり自明で直感的ですが、経験から、プログラマーはスイッチケースで恐ろしいことをする傾向があります。
Duff's Deviceのようなものは可読性に有害であり、かなりの数のまともなコーディング標準(CERT、MISRAなど)に違反しています。これには、ステートメントのような "巧妙な"トリックの使用が含まれます(ステートメントの最後でbreak;
を省略して)。ケースボディの関数を使用する場合、フォールスルーは簡単です。
これはマジックナンバーを回避するためのMISRAホールドオーバーですが、意味がある場合は、ケースラベルで定数リテラルの束をスローしないようにします。それらを意味のある#define
またはconst
識別子で囲み、コードを見ている人が、切り替えている内容に関するコンテキストを持つようにします。
場合によっては、スイッチケースを完全に取り除くことができるため、循環的な複雑さを回避できます。
ケース式の値が連続している場合はジャンプテーブルを使用します
場合によっては、switch-case
をすべて一緒に回避することができます。ケース式の値が連続していて、ケース本体が十分に異なる場合は、関数へのポインターのconstable配列を使用して(C/C++で)呼び出すことができます。
ハッシュを使用するいくつかのより動的な言語では、値を関数のセットにハッシュできる場合があります。
If-Else
場合によっては、スイッチケースが過剰に使用されます。ケース値が2〜3個しかないスイッチケースを使用している場合、スイッチブロックは、同様のパフォーマンスを持つ一連の単純なif-elseブロックとして書き換えられる可能性があります。
悪い
int f;
switch(packet[0]) {
case 0x8A:
f = packet[7] + packet[2];
break;
case 0xBf:
f = packet[6] + packet[3];
case 0xAf:
f = packet[2] + packet[8];
}
良い
status_t status = STATUS_INVALID;
switch(packet_id)
{
case PACKET_ID_ACKNOWLEDGEMENT:
{
status = packet_handle_acknowledge(packet);
break;
}
case PACKET_ID_TEST_RESPONSE:
{
status = packet_handle_test_response(packet);
break;
}
default:
{
status = packet_default_handle(packet);
break;
}
}
Switch-Caseの例の代替
以下の例では、IDに基づいてパケットをディスパッチするためのテーブルpacketHandlers
が示されています。これのPACKET_ID
値が本質的に連続していて、PacketHandler_t
関数が十分に異なる場合、これで十分な場合があります。ただし、PacketHandler_t
sの範囲に使用されている同じPACKET_ID
関数が多数ある場合、スイッチのコードサイズはおそらくより良いでしょう。ただし、ここでのPacket_Dispatch
関数は、スイッチケースよりも複雑さが低くなっています。
#include <stdint.h>
#include <assert.h>
#define PACKET_PAYLOAD_ID_INDEX (0U)
#define PACKET_BYTE_SIZE (8U)
#define PACKET_ID_ACKNOWLEDGEMENT (0U)
#define PACKET_ID_TEST (1U)
#define PACKET_ID_UPPER_RANGE_LIMIT (2U)
#define STATUS_OK (0)
#define STATUS_FAIL (-1)
typedef struct Packet_s
{
uint8_t payload[PACKET_BYTE_SIZE];
}
Packet_t;
typedef int8_t Status_t;
typedef Status_t (*PacketHandler_t)(const Packet_t *p);
Status_t PacketHandler_Acknowledge(const Packet_t *p)
{
/* Perform acknowledgement processing */
return STATUS_OK;
}
Status_t PacketHandler_Default(const Packet_t *p)
{
/* Perform default packet handling */
return STATUS_OK;
}
static const PacketHandler_t packetHandlers[] =
{
[PACKET_ID_ACKNOWLEDGEMENT] = PacketHandler_Acknowledge,
[PACKET_ID_TEST] = PacketHandler_Default
};
Status_t Packet_Dispatch(const Packet_t *p)
{
Status_t status = STATUS_FAIL;
uint8_t packet_id = p->payload[PACKET_PAYLOAD_ID_INDEX];
if(packet_id < PACKET_ID_UPPER_RANGE_LIMIT)
{
PacketHandler_t handler = packetHandlers[packet_id];
status = handler(p);
}
return status;
}
私は、個々の方法でCCを測定することを推奨しています。大きなメソッドを小さなメソッドに分割することが「不正行為」であるとは思わない。
より小さな方法では、(1)ネーミングの向上、(2)論理ステップ間の適切な分離、(3)テスト、および(4)コードの再利用が可能になります。最も重要なのは、メソッドが小さいほど理解が容易になることであり、これはまさに循環的複雑度が達成しようとしていることです。
メソッドを分割することが「不正行為」であるという主張は、総計の方が優れていることを意味します。集計メトリックのみに依存しても、コードの問題を特定するのに役立ちません。たとえば、プログラムの総実行時間が1時間だと言った場合、プログラムのどのコンポーネントが45分かかったかわかりますか。
以上のことから、「メソッドごとの平均の循環的複雑度」に基づいてクラスを「スコアリング」することについての議論があります。平均的な循環的複雑度は、多くの小さなゲッターとセッターによってすぐに歪められることに注意する必要があります。