LoginSignup
552
555

[C#]最新言語仕様を使った『宣言的プログラミング』でバグが少なく可読性の高い高品質なコードを書こう

Last updated at Posted at 2022-03-01

はじめに

LINQの登場後、C#は地道な進化を続け、C# 7で登場したタプルと分解、パターンマッチング、C# 8で登場したswitch式、C# 8,9で強化されたパターンマッチング などによって、C#のプログラミングスタイルは劇的に変化しました。

昔では考えられなかったようなスタイルのコードが記述可能になり、可読性やコードの安定性が飛躍的に向上しています。
そのキーポイントとなるのが、「宣言的プログラミング」です。

この記事では、最新のC#を使ってコードを宣言的に書く手法を紹介します。

やってる人は自然とやっている事だとは思いますが、そうではない人もいると思いますので、そういう方の参考になればと願っています。

宣言的プログラミングとは

宣言的プログラミングとは、「どうやってやるか(how)ではなく何をしたいか(what)を書く」と良く言われますが、なんとなくあいまいです。

これをもう少し具体的にイメージできるような表現で書くと、「変数に再代入を行わないように書く」ということになるでしょう(この定義自体は、whatではなくhowを書いているのであまりよくないですが)。

別の言い方をすると、「副作用のないコードを書く」ということになります。

もちろん、変数に再代入を行わないようにかけばすなわち宣言的プログラミング、というわけではありませんが、指標の一つとして使ってみます。

例えば次のコードは手続き的(非宣言的) です。valueがlimitを超えているかどうかを判定して、結果をisOverLimitに設定しています。

手続き的なコード
int value = 1;
int limit = 2;
bool isOverLimit = false;
if (value > limit)
{
    isOverLimit = true;
}
else 
{
    isOverLimit = false;
}

limit値をvalue値が超えているか(isOverLimit)を判定する為に、if文で分岐させ、それぞれtrueとfalseを代入しています。

これに対して宣言的なコードとは、次のようなものです。

宣言的なコード
int value = 1;
int limit = 2;
bool isOverLimit = (value > limit);

手続き的なコードと宣言的なコードで、いったい何が変わったのでしょうか。

実のところ、やっている事自体はそう変わりません。前者はif文の中でlimitを超えたかどうか判定し、その結果trueかfalseをisOverLimitに代入しています。

後者はlimitを超えたかどうか判断する論理式の結果、trueかfalseがisOverLimitに代入されます。

一緒ですね。

しかし決定的な違いがあり、宣言的なコードではisOverLimitが再代入されていません

つまり、isOverLimitの値は (value > limit) の文で確定していているのです(今のところC#には再代入不可な変数宣言方法がないので、その後の不変を保証できないのが残念ですが)。

これは別の言い方をすれば、isOverLimitの「定義」をコードで行っているということにほかなりません。

このコードはそのまま自然言語に置き換えて読めます。

isOverLimitとは、valueがlimitを超えることです

そしてそれがコードとして実際に動作します。
これが「宣言的プログラミング」です。

宣言的プログラミングで書かれたコードはそれ自体で完結している為、安定しています。

isOverLimitは必ずtrueかfalseの値を取り、その結果は確実に意図通りの結果を返します。なぜなら「意図」がそのまま式として定義されているためです。

しかし、手続き的なコードでは、「どうやってやる」が記述されている為、「どうやって」の部分に間違いがあった場合、それは潜在的な不具合となります。

手続き的なコードには「どうやって」の部分に不具合が入っても気づきにくい
int value = 1;
int limit = 2;
bool isOverLimit = false;
if (value > limit)
{
    isOverLimit = false; // trueとfalseが逆
}
else 
{
    isOverLimit = true; // trueとfalseが逆
}

「宣言的プログラミングだって、意図の部分が間違っていたら不具合になるじゃないか」と反論したくなるかもしれません。

bool isOverLimit = (value < limit); // 大小比較演算子の向きが逆

確かにその通りですが、手続き的プログラミングのように、「どうやって」の部分に不具合が入り込むことはありません。

なぜなら「どうやって」の部分を書いていないからです。そこには「意図」しか書かれていません

半面、手続き的プログラミングでは、「意図」と「どうやって」が混在している為、どこまでが「どうやって」でどこからが「意図」かコードから読み取りにくくなり、不具合の発見も遅れがちです。

int value = 1;
int limit = 2;
bool isOverLimit = false;
if (value > limit)
{
    isOverLimit = false; // trueとfalseが逆
}
else 
{
    isOverLimit = true; // trueとfalseが逆
}
isOverLimit = true; // 誰かが間違えて挿入したコードのせいで常にtrueになる

コンピュータが利用できるメモリ資源が希少だった以前は、一度確保した変数のメモリ領域を何度も使いまわすことで効率化することが良しとされていました。

しかし現代ではメモリ資源は多くのケースで潤沢です。既存の変数を変更せず、目的に応じてどんどん新しい変数を確保し、他のコードからの影響を極小化することが、現代における「良いプログラミング」です。

C#の進化によって、今はどのように宣言的にプログラムを書くことができるようになったのか、見ていきましょう。

まずは、昔からある三項演算子を使う例から始め、switch式へと進化させていきます。swtich式とパターンマッチングの組み合わせ方法を紹介しつつ、最後に、今やおなじみのツールとなったLinqを使った宣言的プログラミングの例を挙げます。

三項演算子

三項演算子とは、「式がtrueの時とfalseの時に異なる値を返す」演算子です。

例えば次のような手続き的なコードがあったとします。

手続き的なコード
string message = "";
if (count > limit)
{
    message = "件数が制限値を超えています";
}

これを、三項演算子を使って宣言的に書いてみましょう。

宣言的なコード
string message = (count > limit) ? "件数が制限値を超えています" : "";

違いが分かるでしょうか。
そうですね、messageが再代入されていません。
messageは初期化と同時に内容が確定します。

三項演算子に慣れていない人は、恐らくこれを見て「なんだか呪文のようでわかりにくいな」と感じるかもしれません。
しかしよく見ると、宣言的なコードは意図が明確になっていることがわかります。

場合によっては次のように整形することもあります。こちらの方が見やすいという人もいるでしょう。

宣言的なコード(整形バージョン)
string message = (
    (count > limit) 
    ? "件数が制限値を超えています" 
    : ""
);

手続き的なコードでは、1行目に空文字でmessageを初期化しています。
そしてこの値がどういう意味を持つかは、その後のコードを全て見て、「count > limitでなかった場合にこの値が初期値として採用されるのだな。つまり、これはcount > limitでなかった場合の値なのだな」と読み取るまで分かりません。

それに対して宣言的なコードでは、(count > limit)の場合、そうでない場合それぞれに値が示され、空文字は「count > limitでなかった場合」の値だということが明確に定義されています。

そう考えると、手続き的なコードよりも読みやすいと感じられるのではないでしょうか。

さらにこの書き方の良いところは、falseの場合に何を設定すべきかを必ず一緒に定義しなければコンパイルが通らないという点です。

手続き的なコードを書いていると、条件に一致した場合の値を書いても、一致しなかった場合の値について考慮が抜けており、後で不具合の原因になったりすることがありますが、三項演算子ならそれはあり得ません。

とはいうものの、三項演算子はちょっととっつきにくいという人もいるかもしれません。
そういう人の為に(というだけではもちろん無いと思いますが)、C# 8から「switch式」が導入されました。

switch式

switch式は、「値を返すswitch文」です。これは劇的にコーディングスタイルを変化させることになります。

先ほどのmessageを返すコードを、旧来のswitch文を使って書いてみましょう。

旧来のswitch文を使った場合
string message = "";
switch (count > limit)
{
    case true:
        message = "件数が制限値を超えています";
        break;
    
    default:
        message = "";
        break;
}

せっかくなのでdefaultも付けて安定させました。なかなか宣言的な感じに書けていますが、残念ながら再代入が行われており、messageが最終的にどうなるかはコード全体を見ないとはっきりとはわかりません。

しかしswitch式を使うと、switch自体が値を返すことができます。つまり、messageをswitch式で初期化できるのです。

switch式を使った場合
string message = (count > limit) switch
{
    true => "件数が制限値を超えています",
    false => ""
};

switch式の構文は以下の形式となります。

var result =  switch 
{ 
    パターン1 => パターン1の時に返す値, 
    パターン2 => パターン2の時に返す値, 
    ... 
};

case文も不要になり、パターン => 結果 を羅列するだけでよくなっています。break文も要りません。

messageへの再代入もなく、このswitch式で結果が確定します。つまり、messageの結果は、switch式の中の合致する条件の場所の => の先を見れば良いだけになります。コードの可読性が飛躍的に向上します。

switch式にはもう一つ良い点があります。

switch式を使った場合
string message = (count > limit) switch
{
    true => "件数が制限値を超えています" // falseの条件が不足している警告が出る
};

上記のように書くと、「falseの時の戻り値が書いてないよ!」とコンパイラが教えてくれるのです。

さて、上記のようなシンプルな論理式だと、switch式の良さはあまり分からないかもしれません。
「三項演算子の方が分かりやすい」という人もいるでしょう。

しかしこれならばどうでしょうか。

列挙型を処理するswitch式
string message = errorType switch 
{
    ErrorType.Warning => "警告",
    ErrorType.Error => "エラー",
    ErrorType.Information => "情報",
    _ => ""
};

もちろんswitch式は、ErrorTypeの全ての列挙型の値を網羅していることを要求します。
最後の _ => ""は、どんな条件でも一致するパターンを定義しています。つまり、上3つのいずれにも一致しない場合には、ここで一致します。

これを書かないでおくと、ErrorType.None の時にmessageの値が不定になってしまうので、コンパイル時点で警告を出してくれるのです。

switch式とパターンマッチング

switch式はパターンマッチングと組み合わせる事でさらに便利になります。
if~elseはもう要らなくなるといっても過言ではありません(すいません、言い過ぎです)。

string message = errorLevel switch 
{
    0 => "エラーなし",
    >= 1 and < 3 => "軽微なエラー",
    >= 3 and < 9 => "通常エラー",
    >= 9 => "重大なエラー"
};

お分かりでしょうか。定数値とマッチするだけでなく、大小比較も可能になっています。
C# 9以降からの機能なので、古いC#では使えませんが、最新のC#環境を使えるなら積極的に使っていくべきでしょう。

複数の変数を組み合わせた条件を付けたければ、タプルを用いることもできます。

const MAX_ROW = 100;
const MAX_COL = 20;
string message = (row, col) switch
{
    ( < 0 or > MAX_ROW, _ ) => "不正な行番号です",
    ( _, < 0 or > MAX_COL ) => "不正な列番号です",
    ( 0, 0 ) => "先頭セルです",
    ( MAX_ROW, MAX_COL ) => "最終セルです",
    _ => $"({row}, {col})"
};

行番号rowが範囲外ならば「不正な行番号です」とメッセージを設定し、列番号colが範囲外ならば「不正な列番号です」と設定しています。

また、先頭セルと最終セルの場合にも特別なメッセージを設定しています。
それ以外ならば、(row, col)形式の文字列を設定しています。

これに慣れてくると、これをif~elseで書くと考えるだけでうんざりしてきます。

複数の変数同士の比較が必要な場合には、「ケースガード」という、追加のwhen条件式を付けてパターンマッチを限定させることができます。

ケースガードを用いたパターンマッチング
string message = (prevWeight, currWeight) switch 
{
    (0, _) or (_, 0) => "0より大きい値を入力して下さい",
    (var prev, var curr) when prev < curr => "体重が増加しています",
    (var prev, var curr) when prev == curr => "体重をキープしています",
    (var prev, var curr) when prev > curr => "体重が減っています"
};

上記のコードで面白いのは、指定したパターンで変数をキャプチャできることです。
(var prev, var curr) は、全ての(prevWeight, curreWeight)がマッチし、それぞれprevcurrにマッチした後の変数がキャプチャされます。
その後、whenを使ってマッチする条件を絞り込んでいます。

タプルを使った別の例も示します。

タプルを用いて複数の変数を組み合わせる
string message = (secCd, empCd) switch 
{
    (null or "", _) => "部門コードが指定されていません",
    (_, null or "") => "社員コードが指定されていません",
    _ => "ようこそ"
}

上記のコードは、部門コードsecCdと社員コードempCdが両方とも指定されていれば「ようこそ」とメッセージを表示したいという定義です。
部門コードと社員コードをタプルとしてswitch式に与え、それに対して「部門コードがnullか空文字の場合(社員コードはチェックしない)」と「社員コードがnullか空文字の場合(部門コードはチェックしない」の場合にエラーメッセージを表示しています。

null or ""を使って「null又は空文字」を表現しています。あのながったらしいString.IsNullOrEmpty(str)から解放されます。

プロパティパターンでのマッチング

パターンマッチングはオブジェクトプロパティにもマッチさせることができます。
これはswitch式に限らずif文やswitch文でも使えますが、今回は宣言的な書き方の紹介ですので、switch式での事例を書きます。

プロパティパターン
// 社員クラス
public class Emp
{
    public string EmpCd {get; set;}
    public string EmpName {get; set;}
    public Dept Dept {get; set;}    // 新入社員の場合はnull
}

// 部署クラス
public class Dept
{
    public string DeptCd {get; set;}
    public string DeptName {get; set;}
}

// 社員コードの社員
var emp = GetEmp(empcd);

// 社員の表示名
string dispEmpName = emp switch 
{
    null or {EmpCd: null} => "(なし)",
    {Dept: null, EmpName: var empName} => $"{empName}",
    Emp e => $"{e.EmpName}({e.Dept.DeptName})"
};

社員Empは、1つのDeptに所属していますが、新入社員などの場合にはDeptがnullの場合もあります。
Deptがnullの場合は部署名は表示しませんが、通常は名前の後に丸括弧で部署名を表示します。

switch式内のパターンを一つ一つ見ていきましょう。

    null or {EmpCd: null} => "(なし)",

上記のパターンは、emp自体がnullの場合と、emp.EmpCd が nullだった場合のパターンです。
データ自体がないので、「(なし)」を返します。

    {Dept: null, EmpName: var empName} => $"{empName}",

上記のパターンは、Deptがnullだった場合のパターンです。
部署名を抜いた、名前だけを返却するパターンです。
EmpNameは表示に使うため、empNameという変数にキャプチャしています。
ここはswitch式に与えられたemp自体を使用しても構いません。その場合は EmpNameは省略します。ただ、マッチした場合にそのマッチした値を戻り値に使っているということを明示する為にも、キャプチャを明示的に書いた方がより宣言的だと思います。

    Emp e => $"{e.EmpName}({e.Dept.DeptName})"

最後は、全てのEmp型にマッチするパターンです。Emp型のオブジェクトをeという変数で丸ごとキャプチャしています。

これまでif文で長々と書いていた(場合によってはnullチェックを含めながら)処理が、非常にスッキリと、意図を明快に記述することができるようになっています。

パターンマッチングと三項演算子の組み合わせ

あまり使う例はないかもしれませんが、三項演算子とパターンマッチを併用することもできます。

上記のEmpとDeptであれば、例えば以下のように書けます。EmpCdを表示する際に、EmpCdがnullなら"(なし)"を返します。

string dispEmpCd = emp is null or {EmpCd:null} ? "(なし)" : emp.EmpCd;

ただ、上記のような例は、null条件演算子null結合演算子を用いた方が分かりやすいかもしれません。

string dispEmpCd = emp?.EmpCd ?? "(なし)";

null条件演算子(?.) は、ドット以前がnullだった場合にドット以降の評価を行わずnullを返します。
null結合演算子(??) は、左辺がnullでなければ左辺を、nullならば右辺を評価値として返します。

ただ、null許容型関連の演算子は慣れてしまえば可読性は高いのですが、慣れない人には呪文のようにも思え、その結果がイメージしにくいかもしれません。

switch式で複数の値を返す

ある条件に基づいて複数の値が返る場合、どうすれば良いでしょうか。
その場合、タプルを用いて返すことができます。

(string message, Color color) = errorLevel switch 
{
    0 => 
        ("エラーなし", Color.Black),
    >= 1 and < 3 => 
        ("軽微なエラー", Color.Blue),
    >= 3 and < 9 => 
        ("通常エラー", Color.Yellow),
    >= 9 => 
        ("重大なエラー", Color.Red)
};

ただ、まったく同じ条件でまとまって返る値というのは普通、タプルではなく一つの型として定義した方が良いかもしれません。

ループを宣言的に書く

ここからは、LINQの少し古い話題となります。
LINQはパターンマッチングと違って昔からあるC#の機能ですが、宣言的プログラミングには欠かせないのでここで簡単にまとめて記載します。

ループを書く時、副作用があるケースと無いケースがあります。
副作用がある、とは、ループの中で、ループ開始前に存在したなんらかの既存の状態を変更しているケースです。

例えば、データのリストをループで回して、各データを1行ずつDBにINSERTするようなケースは、副作用があるケースです。

しかし、恐らく殆どのケースにおいて、ループの副作用は、副作用のないケースに置き換えることができます
副作用があるかないかは、これまでと同様、「変数の再代入がない形になっているかどうか」で判断することができます。

合計の計算

分かりやすい例から行きましょう。
あるデータのリストがあり、そのデータのdecimal Priceプロパティを全て合計したいとします。

昔ながらのループ処理は、次のように書いたことでしょう。

合計値を計算するループ
decimal total = 0m;
foreach (var row in list)
{
    total += row.Price;
}

totalという変数を宣言し、ループの中で次々に加算(変更)していきます。つまり、totalに対する副作用があるコードです。
しかし、LINQの登場後は、上記は次のように宣言的に書けます。

宣言的な合計値の計算
decimal total = list.Sum(row => row.Price);

Sumに渡しているラムダ式は、リストのデータ(row)から、合計したい値(row.Price)を返す式となります。

何が変わったのでしょうか?
そうですね、totalが再代入されていません。つまり、副作用が消えています。
totalの初期化コードも消えています。
「Sum」という名前の処理を呼び出しているので、totalには何かの合計値が入るのだとすぐに理解できます。

合計値を取得するという意図が、明確にコードで示されるようになりました。

ちなみにみなさんは、以下のコードの間違いに瞬時に気づけるでしょうか。

合計値を計算するループ
decimal total;
foreach (var row in list)
{
    total = row.Price;
}

はい、totalが初期化されていないのはミスリードです。
それよりも重要なことに、totalの計算が += ではなく = になっています。
自分で合計値計算処理を書いた場合、こういった些細なミスをする可能性はどうしても排除できません。

しかし、Sumを使えば、上記のようなミスを劇的に減らすことができます。

もしここで「消費税の合計も出して」と言われても、簡単ですね。

宣言的な合計値の計算
decimal totalPrice = list.Sum(row => row.Price);
decimal totalTax = list.Sum(row => row.Tax);

書くコード量が減ると、コードを書く際の足取り(手取り?)も軽く済むというものです。
ところで、上記のようなコードを見て、「リストを2回も計算させているのは無駄が多い」と感じた人もいると思います。
しかし、よほどこの部分が処理上のネックになっているような場合を除き、殆どのケースでこれが問題になるようなことはないでしょう。

まずは上記のように宣言的に書き、どうしてもネックになる場合には、ループに書き直す等すれば良いと思います。

尚、少し使い方が難しいのですが、LinqにはAggregateという、関数型言語でいう畳み込み関数に相当する機能があります。
これは、リストの先頭から順に、「先頭の要素に次の要素を何かしら適用して結果を返す」「その結果にまた次の要素を適用して結果を返す」を繰り返していく処理です。

Aggregateを使ってSumと同等の処理を書く場合、次のようになります。

AggregateでSumを実装
decimal totalPrice = list.Aggregate(0m, (sum, row) => sum + row.Price);

Aggregateの第一引数は、「初期値」です。まずこの初期値が、第二引数のラムダ式の「sum」に与えられます。rowには、listの要素が順に入ります。
そして、sumとrow.Priceが合計された値が返り、これが新しいsumになり、次のrow.Priceと合計されていきます。
全ての要素が処理されると、sumに全てのPriceの合計が入り、それがAggregateの戻りとなります。

これを用いて、totalPriceとtotalTaxをタプルで返すAggregateを書くことができます(型推論がまだこのあたり弱いのか、rowの型が必要になります)。

Aggregateで複数の値を同時にSumする。
(decimal totalPrice, decimal totalTax) = 
    list.Aggregate(
        (0m, 0m), 
        ((decimal price, decimal tax) sum, ShopItem row) => (sum.price + row.Price, sum.tax + row.Tax));

まぁ、これが可読性が高いかと言われると、ちょっと自信がありませんが…。

データの検索

条件に合致するデータの検索は、手続き的には次のように書いていたと思います。

条件に合致するデータを探すループ
strign empName = null;
foreach (var emp in list)
{
    if (emp.EmpCd == searchEmpCd)
    {
        empName = emp.EmpName;
        break;
    }
}

しかし、条件に合致するデータの検索は、Whereを使って簡単に書くことができます。

条件に合致するデータを取得するWhere
var emp = list.Where(row => row.EmpCd == searchEmpCd).FirstOrDefault();
string empName = emp?.EmpName;

Whereは便利なのですが、上記のように、「リストに対象が存在しなかった場合の定義」までキッチリ書く必要があるので、ときにまどろっこしく感じるかもしれません。
上記で言えば、.FirstOrDefault() を必ずつけなければいけません。

.FirstOrDefault()は、Whereの結果から先頭の要素を返すメソッドですが、もし結果が0件だった場合には規定値を返します。Emp型はクラス型なので、規定値はnullとなります。
ちなみに、.First()を呼び出すと、結果が0件の場合に例外がスローされます。

2行目で emp?.EmpNameとしているのは、FirstOrDefault()の結果がnullで、emp変数にnullが入っている場合を想定しています。
nullならば、emp.EmpNameでNullReferenceExceptionがスローされてしまう為です。Null条件演算子を指定しておけば、empがnullの場合にそれ以降が評価されず、nullが返ります。

もしここで、nullの場合には空文字を返したければ、次のように書きます。

条件に合致するデータを取得するWhere
var emp = list.Where(row => row.EmpCd == searchEmpCd).FirstOrDefault();
string empName = emp?.EmpName ?? "";

全部合わせると、1行で書くこともできます。

条件に合致するデータを取得するWhere
string empName = list.Where(row => row.EmpCd == searchEmpCd).FirstOrDefault()?.EmpName ?? "";

ただ、こういう場合はメソッド単位で適宜改行を入れた方が読みやすいでしょう。

条件に合致するデータを取得するWhere
string empName = list.Where(row => row.EmpCd == searchEmpCd)
                     .FirstOrDefault()?.EmpName ?? "";

empNameとは、リスト中のEmpCdが検索値と一致している要素の先頭のEmpNameである。存在しなければ空文字とする」という意味になります。

繰り返し書いているように、この書き方だと「どのように処理するか」ではなく「何をしたいか」が明確になります。

最初のループ処理と比べてどちらが読みやすい、保守しやすいコードだと感じるでしょうか。
慣れていないと、「ループの方がいい」と感じるかもしれませんが、徐々に、「意図が明確に言語仕様で表現されている」ことがメリットだと感じるようになるでしょう。

尚、実はFirstOrDefault自体にWhereの条件を書くこともできるようになっています。

条件に合致するデータを取得する
string empName = list.FirstOrDefault(row => row.EmpCd == searchEmpCd)?.EmpName ?? "";

この方が記述がスッキリする場合もあるかもしれません。

また、検索条件がキーである場合など、取得されるデータが1件または0件であることが保証されている場合には、FirstOrDefaultではなくSingleOrDefaultメソッドを使うこともできます。

キーに合致するデータを取得する
string empName = list.SingleOrDefault(row => row.EmpCd == searchEmpCd)?.EmpName ?? "";

この場合、結果が複数件あると例外がスローされます。そういうチェックも含めてLinqに任せることができるのも便利なところでしょう。

リストを元に別のリストを作る

Emp(社員)クラスとDept(部署)クラスのリストから、Dept毎の所属人数のリストを作って欲しいと言われた場合、どのように書くか考えてみましょう。

作りたいリストは次のようなものです。

Dept毎の所属人数
public class EmpNumberByDept
{
    public string DeptCd {get; set;}
    public int Count {get;  set;}
}

ループ的な考え方ですと、次のように書いていたと思います。

ループで処理
var list = new List<EmpNumberByDept>();
// Dept毎のループ
foreach (var dept in listDept)
{
    int count = 0;
    // 全Empの中からdept.DepcCdに一致するempを数える
    foreach (var emp in listEmp)
    {
        if (emp.Dept?.DeptCd == dept.DeptCd)
        {
            count ++;
        }
    }
    // 数えた結果をEmpNumberByDept型の変数に設定し、listに追加する
    var row = new EmpNumberByDept();
    row.DeptCd = dept.DeptCd;
    row.Count = count;
    list.Add(row);
}

副作用だらけのコードですね。

「リストから別のリストを作る」というのは、「写像」と呼ばれる変換処理です。多くの場合、副作用なしに書くことができ、C#ではLinqのSelectメソッドを用いて宣言的に記述できます。
考え方としては、「DeptのリストをEmpNumberByDeptのリストに変換する写像を書く」というものになります。

Selectで宣言的に記述
var list = listDept.Select( dept => // Deptを...
    new EmpNumberByDept() // EmpNumberByDeptに変換する
    {
        DeptCd = dept.DeptCd,
        Count = listEmp.Where(emp => emp.Dept?.DeptCd == dept.DeptCd).Count();
    }
).ToList();

非常にすっきりとした、宣言的なコードになったと思います。
DeptがEmpNumberByDeptに写像変換されているということが、簡潔にコードに表現されています。

1月から12月までの月毎の売上を計上する

最後に、総仕上げとして少し複雑な例を考えてみます。といっても、先ほどの例の応用になります。

public class Uriage 
{
    public decimal Value {get; set;}    // 売上額
    public string ItemCd {get; set;}    // 商品コード
    public DateTime UriageDate {get; set;}  // 売上日時
}

var listUriage = GetUriageList(year:2022);  // 2022年のUriageのリストを取得

上記のようなレコードがあった場合に、2022年の1月から12月までの月毎の売上をそれぞれ計上してほしいと言われたらどうするでしょうか。listUriageには、既に2022年のUriageのリストが格納されているものとします。

いろいろと思い浮かぶと思いますが、以下に一例を挙げます。
考え方としては、「1~12の数字のリスト」を入力として、「月毎の売上のリスト」が返るものを考えます。
つまり、List<int> を List<decimal>に変換する写像を書くことになります。

最初の「1~12の数字のリスト」は、Enumerable.Rangeを使って生成することができます。

1~12の数字のリストを作る
var monthes = Enumerable.Range(1, 12);  // 1から初めて12個分の連続する整数を生成

この数字のリストをSelectを使って写像変換します。それぞれの月monthに対して、listUriageのSumを取得して返します。

1~12月の売上リストを生成
var listMonthlyUriage = Enumerable.Range(1, 12).Select(month => 
    listUriage
        .Where(uri => uri.UriageDate.Month == month)
        .Sum(uri => uri.Value) 
).ToList();

listMonthlyUriageは、Sumの結果、つまりdecimalのリストとなります。
これが1~12月の分、それぞれ格納されており、0~11のインデックスに対応します。

decimal uriage1 = listMonthlyUriage[0];
decimal uriage2 = listMonthlyUriage[1];
  :
decimal uriage12 = listMonthlyUriage[11];

いかがでしょうか。想像していたコードと同じだったでしょうか。

GroupByを使ったグルーピング

最後と言いましたが、応用例をもう少し挙げておきます。

先ほどの例を、今度は「商品コード毎に集計」してみます。
とりあえず、変換後のデータは「商品コード」と「金額」が必要ですので、このデータの型を定義します。

商品コード毎の売上を表す型
public class UriageByItem 
{
    // 商品コード
    public string ItemCd {get; set;}
    // 売上
    public decimal Value {get; set;}
}

上記の型のリストを作ればよいわけですが、先ほどのように「1~12の数字」に相当する「全ての商品コードのリスト」がありません。
あったとしても、全ての商品コードについて処理をすることは求められておらず、必要なのは「売上リストに存在する商品コードのみの商品別売上リスト」です。

このような場合、まず、売上リストを「商品コード毎にグループ化したデータのリスト」という形に変換します。
それを行うのがGroupByメソッドです。
グループ化した後、Selectメソッドを使って、グループ単位でデータを処理します。

GroupByメソッド
var listUriageByItem = listUriage
    .GroupBy(uri => uri.ItemCd) // この値が同じものがグループ化される
    .Select(group => new UriageByItem() { // groupにはGroupByでグループ化されたリストが与えられる
        ItemCd = group.Key, // GroupByに指定した値、つまり商品コードがKeyに格納されている
        Value = group.Sum(uri => uri.Value) // groupのSumを計算すれば良い
    }).ToList();

上記の結果で上がるlistUriageByItemは、次のようなデータとなります。

商品コード毎の売上を出力
foreach (var uri in listUriageByItem)
{
    Console.WriteLine($"商品コード:{uri.ItemCd}, 売上合計:{uri.Value}円");
}

コードが意図をそのまま表しており、バグの入り込む余地がありません。

メソッドを宣言的に書く

これまでのような宣言的なコードの書き方をしていくと、メソッドも宣言的に書けるようになっていきます。

メソッドの中身がreturn文だけになる感じです。

public class Logic1
{
    private List<Uriage> _list;
    
    public Logic1(List<Uriage> list)
    {
        _list = list;
    }
    
    public decimal GetUriageByItemCd(string itemCd)
    {
        return _list
            .Where(uri => uri.ItemCd == itemCd)
            .Sum(uri => uri.Value);
    }
}

上記のような形まで宣言的に書ける場合、もっと省略して、メソッド自体を => を使って次のように書けます。

public class Logic1
{
    private List<Uriage> _list;
    
    public Logic1(List<Uriage> list)
    {
        _list = list;
    }
    
    public decimal GetUriageByItemCd(string itemCd) =>
        _list
            .Where(uri => uri.ItemCd == itemCd)
            .Sum(uri => uri.Value);
}

なんだかかっこいい感じですが、慣れるまではぎょっとしてしまうかもしれませんね。

副作用が処理の主目的なケース

ループの中でDBにINSERTしているケースなど、「最終的な副作用自体が処理の主目的」であるケースがあります。
このような場合はもちろん、全てを宣言的に書くことはできません。

しかし、INSERTする前のデータの集計や整形処理などは宣言的に書き、最後にループを回してINSERTする部分だけforeachで書くなど、可能な限り宣言的に書く対応をした方が良いでしょう。

副作用がある部分以外の処理を宣言的に書いておく
// 絞り込み・グルーピング・集計処理
var list = listInput
    .Where(row => 絞り込み条件)
    .GrupBy(row => グルーピング条件)
    .Select(group => 集計処理)
    .ToList();

// DB保存用の写像変換
var listForDB = list.Select( row => DB用写像変換 ).ToList();

// DBへの保存
foreach (var row in listForDB)
{
    InsertToDB(row);
}

上記のように目的ごとに段階を踏んで宣言的に記述しておくと(このように、結果を次の処理の入力にしていく流れをパイプ処理と言います)、もしこの後「DBに保存する際の変換処理に変更が入った」場合でも、その影響範囲を特定しやすくなります。

また、上記の処理と同時に帳票出力を行いたい場合でも、既存の処理に影響を与えずに処理を書くことができるでしょう。

帳票出力処理を後から追加
// 絞り込み・グルーピング・集計処理
var list = listInput
    .Where(row => 絞り込み条件)
    .GrupBy(row => グルーピング条件)
    .Select(group => 集計処理)
    .ToList();

// DB保存用の写像変換
var listForDB = list.Select( row => DB用写像変換 ).ToList();

// DBへの保存
foreach (var row in listForDB)
{
    InsertToDB(row);
}

// 帳票出力用の写像変換
var listForReport = list.Select( row => 帳票用写像変換 ).ToList();

// 帳票出力
foreach (var row in listForReport)
{
    OutputReport(row);
}

もしDBへの保存処理の中に絞り込み処理とDB用写像変換を全部ループで書いていたら・・・と考えると、ぞっとしませんか?

もしループで書いていたら…ここに帳票出力処理を追加してください
string groupCd = "";
string empName = "";
decimal hogeTotal = 0m;
  :
foreach (var row in listInput)
{
    if ( 絞り込み条件 )
    {
        // グルーピング
        if (groupCd != row.DeptCd)
        {
            if (groupCd != "")
            {
                // DB出力処理
                InsertToDB(groupCd, empName, hogeTotal, ... );
            }
            
            // グループ情報
            groupCd = row.DeptCd;
            empName = row.EmpCd + ":" + row.EmpName;
            hogeTotal = 0m;
        }
        
        // 集計処理
        hogeTotal += row.hoge;
           :
           
        // DB用の整形処理
           :
           
    }
}

上記のような、「どうやって」と「何をしたいか」がすべて混然一体となった処理、あなたもどこかで見たことありませんか?
ひょっとするとあなた自身がこういうコードを今でも書いているかもしれません。

ここに帳票出力処理を加えようとすると、既存の処理にも手を加える事になる為、テストはやりなおしになります。既存の処理に変な不具合が入り込む可能性も大いにあるでしょう。

もう何もかもめんどくさくなって、コードをまるごとコピペして帳票出力用の処理(絞り込みや集計部分はDB保存処理とまったく一緒)を別途作り始める姿が目に浮かびます。

ForEachメソッドではなくforeach文を使う

ところで、リスト型にはForEachというメソッドがあり、以下のように使う事ができます。

ForEachメソッドを使用したループ処理
listForReport.ForEach(row => 
{
    OutputReport(row);
});

しかし、ForEachメソッドは一見何か結果を返しているように見えてその実何も返さない副作用を前提とした処理です。
であれば、目に見えて副作用がある事が分かるforeach文を使って記述した方が、意図もはっきりして見た目にもすっきりすると思います。

その方が、ループ内部でbreakやcontinueなども使えますしね。

foreach文を使用したループ処理
foreach (var row in listForReport) 
{
    OutputReport(row);
}

念の為、ForEachメソッドを絶対に使ってはいけないという事ではありません。
例えば次のような場合、簡潔にメソッドを記述できます。

// 社員のリストを与えると社員名をコンソールに出力するデバッグ用関数
var debugLog = (List<Emp> list) => list.ForEach( emp => Console.WriteLine(emp.EmpName));
debugLog(
    new List<Emp>{ 
        new() {EmpCd = "1111", EmpName = "test1"}, 
        new() {EmpCd = "2222", EmpName = "test2"}, 
        new() {EmpCd = "3333", EmpName = "test3"}, 
    }
);

そして上記のdebugLog関数は、次のように書く事もできるのです。

var debugLog = (List<Emp> list) => list
    .Select(emp => emp.EmpName)
    .ToList()
    .ForEach(Console.WriteLine);

上記の定義は「社員リストを社員名リストに写像したものについて、それぞれコンソール出力する」となります。
ForEachに与える「実行すべき処理」自体を場合によって付け替えたりしたい場合に、処理に適切な名前を付与して宣言的に書くことができるでしょう。

とはいえ上記の書き方は関数型プログラミングに慣れていないと、何が起きているのか一瞬分からない可能性もあります。
副作用のあるコードを書く際には、基本的にはforeachループを使ってください。

ForEachやforeach内でコレクション自体に追加や削除をしたい場合

(2022-11-17 コメント欄より追記しました)

社員のリストをforeachして、給料が4000ドル以上の社員がいたら首にする(リストから削除する)には、どうしたらよいでしょう。

System.InvalidOperationExceptionが起こる例
var list = new List<Emp>(){
	new Emp {EmpCd = 1, EmpName = "社員1", Salary = 1000},
	new Emp {EmpCd = 2, EmpName = "社員2", Salary = 2000},
	new Emp {EmpCd = 3, EmpName = "社員3", Salary = 3000},
	new Emp {EmpCd = 4, EmpName = "社員4", Salary = 4000},
	new Emp {EmpCd = 5, EmpName = "社員5", Salary = 5000},
};

// 給料が4000ドル以上の社員がいたら首にする
foreach(var emp in list){
	if (emp.Salary >= 4000) {
		list.Remove(emp); // 首だっ!(雇用主の夢)
	}
}

しかしこれは、実行時にSystem.InvalidOperationException例外となります。
これはForEachを使った時も同様です。
それも当然、リストのN番目の処理をしている時にリスト自体から要素を削除して、そもそもの「N番目」という番地すらズレてしまうような処理をしたら挙動がおかしくなりますから、禁止されているのです。

宣言的で副作用のない記述という意味では、これはWhereを使って「除外したい要素を除いた新しいリストを作る」で対応します。

除外したい要素を除いた新しいリストを作る
var newlist = list.Where( emp => emp.Salary < 4000 ).ToList(); // 給与4000ドル未満しかいない世界

ただ、時には、list自体に変更を加えなければならない局面もあるかもしれません(そんなことのないようにしたいですが)。
そんな場合は、宣言的でも副作用のないコードでもありませんが、次のRemoveAllメソッドを使って、条件に一致する要素を全て削除できます。

どうしてもlist自体から要素を削除したい場合(なるべく避け、新しいリストを作るようにしましょう)
list.RemoveAll( emp => emp.Salary >= 4000 ); // 給与4000ドル以上には消えてもらいます

RemoveAllはどうしても元のリスト自体に変更を加えなければならない時だけ用い、基本的には「与えられたリストから、目的に合った別のリストを作って返す」という考え方で処理できないかを考えましょう。

おまけ:オブジェクトの初期化

オブジェクトの初期化も、宣言的に書く事が出来る場合はそうした方が良いでしょう。
最近はIDEが記述を自動訂正してくれたりもするので使っている人も増えていると思いますが、念の為まとめておきます。

オブジェクト初期化子

Empクラスの初期化を、次のように書く事ができます。

オブジェクト初期化子
Emp emp = new() 
{
    EmpCd = "1234",
    EmpName = "テスト氏名", // 最後のカンマを付けてもコンパイルエラーにならないので便利
};

varで宣言する場合はこんな感じです。デフォルトコンストラクタを使う場合は()を省略できます。

オブジェクト初期化子
var emp = new Emp 
{
    EmpCd = "1234",
    EmpName = "テスト氏名", // 最後のカンマを付けてもコンパイルエラーにならないので便利
};

コレクション初期化子

ListなどIEnumerableを実装するコレクション型で、Addメソッドを持っている場合、コレクション初期化子を使って初期化できます。

コレクション初期化子
var list = new List<string> { "abc", "def", "ghi" };

var empList = new List<Emp> 
{
    new() { EmpCd = "1111", EmpName = "テスト氏名1111" },
    new() { EmpCd = "2222", EmpName = "テスト氏名2222" },
    new() { EmpCd = "3333", EmpName = "テスト氏名3333" }, // 最後のカンマを付けてもコンパイルエラーにならないので便利
};

Dictionary型の場合、Add(TKey, TValue)メソッドを持っている為、次のように簡潔に書く事ができます。

Dictionaryの初期化
var empDic = new Dictionary<string, string> 
{
    { "1111", "テスト氏名1111" },
    { "2222", "テスト氏名2222" },
    { "3333", "テスト氏名3333" },     
};

配列の初期化

配列の場合、次のようにプリミティブ型を指定する必要があります。

配列の初期化
var list = new string[] {"abc", "def", "ghi"};

但し、変数の宣言と同時に初期化する場合には、型名を省略することができます。

配列の初期化(型の省略)
var list = new[] {"abc", "def", "ghi"};

匿名型の初期化

その場でのみ使う匿名型の初期化は、次のように使います。

匿名型の初期化
var empView = 
{
    Id = emp.EmpCd,
    Name = $"{emp.EmpName}({emp.Dept?.DeptName})",
};

Console.WriteLine($"{empView.Id}:{empView.Name}");

多くの場合、匿名型は、LinqのSelectメソッドにてその場限りの写像先として使用します。

Selectメソッドで匿名型に写像する
var empViewList = empList.Select(emp => new 
{
    Id = emp.EmpCd,
    Name = emp switch 
    {
        {Dept : null} e => $"{e.EmpName}",
        var e => $"{e.EmpName}({e.Dept.DeptName})",
    },
}).ToList();

foreach(var empView in empViewList)
{
    Console.WriteLine($"{empView.Id}:{empView.Name}");
}

まとめ

  • 三項演算子を使った宣言的なコーディング
  • switch式を使った宣言的なコーディング
  • Linqを使った宣言的なコーディング
  • メソッドの宣言的な書き方
  • 副作用が主目的な場合のコーディング

以上について紹介しました。

宣言的プログラミングで、バグの少ない、高品質なコードを書いていきましょう。

関連記事

[C#]GroupByメソッドで帳票出力処理をスッキリ書いてみる
https://qiita.com/jun1s/items/bf75caa9bf3629e7f603

参考記事

現代のオブジェクト指向の class の割れ窓化と宣言的プログラミング
https://zenn.dev/mizchi/articles/oop-think-modern

552
555
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
552
555