美しいプログラムを書く(リスト処理ライブラリ編)


2011年 06月 01日

問題

美しいプログラムを書く(脱添字職人編)
では添字が多用され読み難くなっているソースコードのリファクタリングを通して
美しいプログラムを書くためのポイントをいくつか紹介しました。
そこでは

  • 「何をするか」を基準にプログラムを書きましょう。
    「どうやるか」が前面に出たプログラムは意図を把握し難くなります。
  • 添字や明示的なループの使用は避けましょう。「どうやるか」が前面に出てきてしまいます。
    今時のプログラミング言語ならば便利な構文やライブラリ関数があるので、
    添字やループを使わずとも「何をするか」を基準にプログラムが書き易くなっています。

ということを述べました。

確かにごもっともな主張ではありますが、経験値の少ない人からすれば
「そんなことを言われてもどんなライブラリ関数があってどういう場面で使えるのかリファレンスを読んでもよく分からないし……」
ということはよくあります。

という訳で、簡単な例題を通して、
ライブラリ関数を適切に使えばプログラムを簡潔かつ明瞭にできることを実際に体験していきましょう。
どのようなプログラミング言語やアプリケーションの作成でも
「ちょっとした数のデータを相手に何か処理を行う」(=リスト処理を行う)
機会は頻繁にありますので、今回はリスト処理ライブラリを使うことにします。

前提

  • 使用する言語: C# 3.0
  • 使用するリスト処理ライブラリ: LINQ
  • サンプルデータ:
		class Language
		{
			public String Name {get; set;}
			public Int32 AppearedIn {get; set;}
			public IEnumerable<String> TypingDiscipline {get; set;}
		}

        var languages = new [] {
            new Language {
                Name = "C#",
                AppearedIn = 2001,
                TypingDiscipline = new [] {
                    "Dynamic",
                    "Nominative",
                    "Safe",
                    "Static",
                    "Strong",
                },
            },
            new Language {
                Name = "Haskell",
                AppearedIn = 1990,
                TypingDiscipline = new [] {
                    "Inferred",
                    "Static",
                    "Strong",
                },
            },
            new Language {
                Name = "JavaScript",
                AppearedIn = 1995,
                TypingDiscipline = new [] {
                    "Duck",
                    "Dynamic",
                    "Weak",
                },
            },
            new Language {
                Name = "Lisp",
                AppearedIn = 1958,
                TypingDiscipline = new [] {
                    "Dynamic",
                    "Strong",
                },
            },
            new Language {
                Name = "Python",
                AppearedIn = 1991,
                TypingDiscipline = new [] {
                    "Duck",
                    "Dynamic",
                    "Strong",
                },
            },
            new Language {
                Name = "Ruby",
                AppearedIn = 1995,
                TypingDiscipline = new [] {
                    "Duck",
                    "Dynamic",
                    "Strong",
                },
            },
            new Language {
                Name = "Scheme",
                AppearedIn = 1975,
                TypingDiscipline = new [] {
                    "Dynamic",
                    "Strong",
                },
            },
        };

リスト内の各データを変換する(Select)

languages から各プログラミング言語の名前のみを抽出してみましょう。

ベタに書いた場合

var names = new List<String>();
foreach (var l in languages)
    names.Add(l.Name);
// names ==> new [] {"C#", "Haskell", "JavaScript", "Lisp", "Python", "Ruby", "Scheme"}

リスト処理ライブラリを使った場合

var names = languages.Select(l => l.Name);

補足

リスト内の各データと一緒にインデックスも必要な場合も少なからずあるので、
Select にはインデックスも付いてくるバージョンも用意されています:

    languages.Select((l, i) => String.Format("{0}. {1}", i, l.Name));
    // ==> new [] {"0. C#", "1. Haskell", "2. JavaScript", "3. Lisp", "4. Python", "5. Ruby", "6. Scheme"}

リストから特定条件に合致するデータを抽出する(Where)

languages から初出の年が1990年よりも昔のものを抽出してみましょう。

ベタに書いた場合

var oldies = new List<Language>();
foreach (var l in languages) {
    if (l.AppearedIn < 1990)
        oldies.Add(l);
}
// oldies.Select(l => l.Name) ==> new [] {"Lisp", "Scheme"}

リスト処理ライブラリを使った場合

var oldies = languages.Where(l => l.AppearedIn < 1990);

リストから特定条件を満たすデータが1つでもあるかどうか判定する(Any)

languages から2000年以降に出現した言語が存在するかどうか判定してみましょう。

ベタに書いた場合

var exists = false;
foreach (var l in languages) {
    if (2000 <= l.AppearedIn)
        exists = true;
}
// exists ==> true (C#が該当する)

リスト処理ライブラリを使った場合

var exists = languages.Any(l => 2000 <= l.AppearedIn);

リストの全データが特定条件を満たすかどうか判定する(All)

languages 中の言語が全て動的型付けかどうか判定してみましょう。

ベタに書いた場合

var valid = true;
foreach (var l in languages) {
    if (!l.TypingDiscipline.Contains("Dynamic"))
        valid = false;
}
// valid ==> false (Haskellが該当しない)

リスト処理ライブラリを使った場合

var exists = languages.All(l => l.TypingDiscipline.Contains("Dynamic"));

リストの各データから最小値/最大値を求める(Min/Max)

languages から初出が最も古い年および新しい年を求めてみましょう。

ベタに書いた場合

var oldestYear = Int32.MaxValue;
foreach (var l in languages) {
    if (l.AppearedIn < oldestYear)
        oldestYear = l.AppearedIn;
}
// oldestYear ==> 1958 (Lisp)

var newestYear = Int32.MinValue;
foreach (var l in languages) {
    if (newestYear < l.AppearedIn)
        newestYear = l.AppearedIn;
}
// newestYear ==> 2001 (C#)

リスト処理ライブラリを使った場合

var oldestYear = languages.Select(l => l.AppearedIn).Min();
var newestYear = languages.Select(l => l.AppearedIn).Max();

補足

  • MinMax は最小値/最大値ですが、合計値や平均値を求める SumAverage もあります。

リストから重複データを取り除く(Distinct)

languages の初出年の一覧(ただし重複分は除く)を求めてみましょう。

ベタに書いた場合

var years = new List<Int32>();
foreach (var l in languages) {
    if (!years.Contains(l.AppearedIn))
        years.Add(l.AppearedIn);
}
// years ==> new [] {2001, 1990, 1995, 1958, 1991, 1975} (JavaScriptとRubyは1995年生まれ)

リスト処理ライブラリを使った場合

var years = languages.Select(l => l.AppearedIn).Distinct();

リストの各データをソートする(OrderBy/ThenBy)

languages の各言語を初出年でソートしてみましょう。
さらに初出年が同じ言語については名前でソートしてみましょう。

ベタに書いた場合

var sortedLanguages = languages.ToArray();
Array.Sort<Language>(sortedLanguages, (Comparison<Language>)((left, right) => {
    var a = left.AppearedIn.CompareTo(right.AppearedIn);
    if (a != 0)
        return a;
    return left.Name.CompareTo(right.Name);
}));
// sortedLanguages.Select(l => l.Name)
// ==> new [] {"Lisp", "Scheme", "Haskell", "Python", "JavaScript", "Ruby", "C#"}

リスト処理ライブラリを使った場合

var sortedLanguages = languages.OrderBy(l => l.AppearedIn).ThenBy(l => l.Name);

補足

  • OrderByThenBy は昇順ソートですが、逆順ソートを行うための OrderByDescendingThenByDescending もあります。
  • OrderByOrderByDescending はソートを行いますが、ソートではなく単にリストの中身を逆順にするための Reverse もあります。

リストの最初の要素n個を取得する/除去する(Take/Skip)

languages から初出年が最も古いものを3つ取得または除去してみましょう。

ベタに書いた場合

var takenLanguages = new List<Language>();
var takenCount = 0;
foreach (var l in languages.OrderBy(l => l.AppearedIn)) {
    takenCount++;
    if (takenCount <= 3)
        takenLanguages.Add(l);
}
// takenLanguages.Select(l => l.Name) ==> new [] {"Lisp", "Scheme", "Haskell"}

var skippedLanguages = new List<Language>();
var skippedCount = 0;
foreach (var l in languages.OrderBy(l => l.AppearedIn)) {
    skippedCount++;
    if (3 < skippedCount)
        skippedLanguages.Add(l);
}
// skippedLanguages.Select(l => l.Name) ==> new [] {"Python", "JavaScript", "Ruby", "C#"}

リスト処理ライブラリを使った場合

var takenLanguages = languages.OrderBy(l => l.AppearedIn).Take(3);
var skippedLanguages = languages.OrderBy(l => l.AppearedIn).Skip(3);

補足

個数による指定の Take/Skip だけでなく、
任意の条件を指定することができる TakeWhile/SkipWhile もあります:

languages.TakeWhile(l => l.AppearedIn % 2 == 0)

リストから抽出したデータのリストを1つのリストにまとめる(SelectMany)

languages から各言語の型付けの種類を抽出して重複を取り除いてみましょう。

ベタに書いた場合

var ts = new List<String>();
foreach (var _ts in languages.Select(l => l.TypingDiscipline))
    ts.AddRange(_ts);
var typingDiscipline = ts.Distinct();
// typingDiscipline ==> new [] {"Dynamic", "Nominative", "Safe", "Static", "Strong", "Inferred", "Duck", "Weak"}

リスト処理ライブラリを使った場合

var typingDiscipline = languages.SelectMany(l => l.TypingDiscipline).Distinct();

リストを元にディクショナリを作成する(ToDictionary)

languages からキーが言語名で値がその言語の情報であるようなディクショナリ
(他の言語ではマップやハッシュテーブルや連想配列と呼ばれているもの)
を作成してみましょう。

ベタに書いた場合

var languageTable = new Dictionary<String, Language>();
foreach (var l in languages)
    languageTable[l.Name] = l;  // あれ? 同一キーを持つ値が複数あったらどうするの?
// languageTable.Keys.OrderBy(k => k)
// ==> new [] {"C#", "Haskell", "JavaScript", "Lisp", "Python", "Ruby", "Scheme"}

リスト処理ライブラリを使った場合

var languageTable = languages.ToDictionary(l => l.Name);

補足

例えば「キーが言語名で値が初出年」のように、
ToDictionary にはキーだけでなく値の選択もカスタマイズできるバージョンも用意されています。

var languageTable = languages.ToDictionary(l => l.Name, l => l.AppearedIn);

リストを元にディクショナリを作成する(ToLookup)

ToDictionary は変換元のリストにおいてキーと値が一対一に対応するという前提があります
(もし同じキーに対して対応する値が複数ある場合は例外が送出されます)。
大抵の場合はこれで問題はないのですが、
同じキーに対して対応する値が複数あるようなディクショナリを作りたい場合も少なからずあります。

例えばキーが初出年で値が言語名であるようなディクショナリを作る場合、
サンプルデータでは1995年生まれの言語としてJavaScriptとRubyがあるため、
値としてどちらを採用すれば良いかが分かりません。

という訳でそのようなディクショナリを作ってみましょう。

ベタに書いた場合

var languageTable = new Dictionary<Int32, IList<String>>();
foreach (var l in languages) {
    if (!languageTable.ContainsKey(l.AppearedIn))
        languageTable[l.AppearedIn] = new List<String>();
    languageTable[l.AppearedIn].Add(l.Name);
}
// languageTable ==> new Dictionary<Int32, IEnumerable<String>> {
//   {1958, new [] {"Lisp"}},
//   {1975, new [] {"Scheme"}},
//   {1990, new [] {"Haskell"}},
//   {1991, new [] {"Python"}},
//   {1995, new [] {"JavaScript", "Ruby"}},
//   {2001, new [] {"C#"}},
// }

リスト処理ライブラリを使った場合

var languageTable = languages.ToLookup(l => l.AppearedIn, l => l.Name);

リストに対して任意の処理を行って結果をまとめる(Aggregate)

これまで SelectWhereToDictionary 等々のLINQの便利なAPIを紹介してきました。
九分九厘の作業はこれらを使うだけで済ませられるのですが、
稀にリスト全体に対して独自の処理をしたい場合が発生します。
しかし、そのほとんどは
「リストに対して任意の処理を行う」
「処理した結果を何らかの形でまとめる」
というパターンに当てはまります。

例えば languages から各言語の初出年のうち最新のものを求めてみましょう
(これは Max を使えばできるのですが、使用例なのであまり気にしないでください)。

ベタに書いた場合

var newestYear = Int32.MinValue;
foreach (var l in languages) {
    if (newestYear < l.AppearedIn)
        newestYear = l.AppearedIn;
}
// newestYear ==> 2001 (C#)

リスト処理ライブラリを使った場合

var newestYear = languages.Aggregate(
    Int32.MinValue,
    (newerYear, l) => (newerYear < l.AppearedIn ? l.AppearedIn : newerYear)
);

補足

上記の例のように、実のところこれまで紹介してきたAPIのほとんどが
「リストに対して任意の処理を行う」
「処理した結果を何らかの形でまとめる」
のパターンになっています。
ですので Aggregate を用いた形で表現可能です。

Aggregate そのものは汎用的過ぎるため、ぱっと見て意図が通じ難くなります。
なので以下のようにアプリケーション固有のライブラリ関数の実装に使う方が良いでしょう。

static public class ExtensionMethods
{
    static public IEnumerable<Int32> MyMax<Int32>(this IEnumerable<Int32> sequence)
    {
        return sequence.Aggregate((greater, value) => (greater < value ? value : greater));
    }
}

他にも

  • その他まだまだ熱いAPIが用意されていますが、詳しくは System.Linq.Enumerable を参照してください。
  • 本当はLINQはただのリスト処理ライブラリに留まらない情熱的な技術の結晶体なのですが、それはまた別の話としておきます。