Advent LINQ (8): ElementAt

前回に続き、もう一つ別の例を考える。Enumerable.ElementAt()だ。このメソッドは、列挙子に対してインデックス値で指定された位置の要素を取得する。

public static class LinqExtensions
{
    public static T ElementAt<T>(this IEnumerable<T> enumerable, int index)
    {
        var i = 0;
        foreach (var value in enumerable)
        {
            if (i >= index)
            {
                return value;
            }
            i++;
        }
        throw new IndexOutOfRangeException();
    }
}

これもまた、列挙子を実際に列挙しなければ、目的の値を得ることが出来ない。Listや配列であれば、インデクサアクセスが可能なはずだ。そこで:

public static class LinqExtensions
{
    public static T ElementAt<T>(this IEnumerable<T> enumerable, int index)
    {
        // IListにキャスト出来れば
        var list = enumerable as IList;
        if (list != null)
        {
            // 直接要素を得る(非ジェネリックなのでT型へキャストが必要)
            return (T)list[index];
        }

        var i = 0;
        foreach (var value in enumerable)
        {
            if (i <= index)
            {
                return value;
            }
            i++;
        }
        throw new IndexOutOfRangeException();
    }
}

IListを使えば、インデクサによるアクセスが可能だ。これにより、要素を列挙しなくても、任意の位置の値を直接引き出す事が出来る。更に、.NET 4.5からはIReadOnlyListもサポートされるようになったので、このインターフェイスへのキャストも試みると良いだろう。

Advent LINQ (7): Count

Enumerable.Count()メソッドの特徴を考えてみる。ただ要素数を数えるだけなら、以下のような実装で良い筈だ。

public static class LinqExtensions
{
    public static int Count<T>(this IEnumerable<T> enumerable)
    {
        var count = 0;
        foreach (var value in enumerable)
        {
            count++;
        }
        return count;
    }
}

当たり前だが、これでは要素を全て列挙しなければ個数が分からない。例えば:

// 100000000個の値が入ったリスト
var values = Enumerable.Range(0, 100000000).ToList();

// 有意に時間がかかる
var count1 = LinqExtensions.Count(values);

// こちらは一瞬
var count2 = Enumerable.Count(values);

何故、Enumerable.Count()は速いのだろうか。それは、列挙子の実行時の型を見て最適化を行っているからだ。ICollectionインターフェイスを実装しているクラスであれば、Countプロパティを参照するだけで個数が識別出来る。この方法を利用するには、以下のようにキャストで判定する。

public static class LinqExtensions
{
    public static int Count<T>(this IEnumerable<T> enumerable)
    {
        // 列挙子がICollectionを実装していれば
        var collection = enumerable as ICollection;
        if (collection != null)
        {
            // 直接Countプロパティを参照する
            return collection.Count;
        }

        var count = 0;
        foreach (var value in enumerable)
        {
            count++;
        }
        return count;
    }
}

列挙子として配列を使用する場合、配列の長さを調べるにはLengthプロパティを参照する。しかし、配列は暗黙にICollectionインターフェイスを実装しているため、わざわざ配列かどうかを確認しなくても、上記コードがそのまま使える。

Advent LINQ (6): Match

Regexクラスを使うと正規表現を使って文字列のキャプチャが出来る。多少なりとも簡単に連続判定できるようにしてみる。

public static class LinqExtensions
{
    public static IEnumerable<Match> Match(
        this IEnumerable<string> sentences,
        string pattern,
        RegexOptions options = RegexOptions.Compiled | RegexOptions.Multiline)
    {
        var regex = new Regex(pattern, options);
        foreach (var sentence in sentences)
        {
            foreach (Match match in regex.Matches(sentence))
            {
                yield return match;
            }
        }
    }
}

これで、引数に正規表現パターンを指定するだけで、連続する文字列に処理が可能となった。

// 文字列中に存在するIPv4アドレスを抽出する
var sampleDatas = new[] { "IP:133.0.0.0", "IP:133.255.255.255 primary", "[192.50.0.0]", "IPn192.50.255.255n" };
foreach (var match in
    sampleDatas.Match(@"(?<1>d+)(?:.(?<1>d+)){3}"))
{
    Console.WriteLine(match);
}

上記の例では、ハードコードされた文字列を扱っているが、Advent LINQ (2)で示したTextReader.AsEnumerableと組み合わせれば、LINQクエリ一文でファイルからの正規表現検索が実現する。壮大な時計仕掛けをやってみよう。

// 指定されたフォルダ配下のテキストファイル群からIPv4アドレスを抽出する(並列実行)
foreach (var match in
    from path in Extensions.FilesAsEnumerable(@"C:project", "*.txt").AsParallel()
    from match in File.OpenText(path).AsEnumerable().Match(@"(?<1>d+)(?:.(?<1>d+)){3}")
    select match)
{
    Console.WriteLine(match);
}

MatchはIEnumerable<string>を受け取るようになっているので、列挙子ではなく単一の文字列を受けるには、1要素の配列を作る必要がある。この部分が分かりにくいのであれば、Match(this string sentence, …) のような、単一の文字列だけを受け取るようなメソッドに変更し、明示的にselect句で射影するようにしてもよい。ただ、その場合は、正規表現をコンパイルするメリットが失われるかもしれない。
参考: .NET Frameworkがサポートする正規表現クラスを徹底活用する

Advent LINQ (5): Flatten

Advent LINQ (3)で、フォルダを探索しながらファイルを列挙するFilesAsEnumerableを作った。このような、木構造のシーケンスを探索するというシチュエーションは、色々ありそうだ。なので、このアルゴリズムを一般化する事を考える。

public static class LinqExtensions
{
    public static IEnumerable<U> Flatten<T, U>(
        this T node,
        Func<T, IEnumerable<T>> predictBySubNode,
        Func<T, IEnumerable<U>> predictByNode)
    {
        var bySubNode =
            from subNode in predictBySubNode(node)
            from childNode in Flatten(subNode, predictBySubNode, predictByNode)
            select childNode;

        var byNode =
            from childNode in predictByNode(node)
            select childNode;

        return bySubNode.Concat(byNode);
    }
}

これを使ってファイルの探索を行うには、以下のように記述する。

var baseDirectory = new DirectoryInfo(@"C:project");
foreach (var file in baseDirectory.Flatten(
    directory => directory.GetDirectories(),
    directory => directory.GetFiles("*.cs")))
{
    Console.WriteLine(file.FullName);
}

XmlDocumentから、テキストノードを抜き出してみる。

var document = new XmlDocument();
document.Load(@"C:projectdata.xml");
foreach (var text in document.DocumentElement.Flatten(
    element => element.SelectNodes("*").Cast<XmlElement>(),
    element => element.SelectNodes("text()").Cast<XmlText>()))
{
    Console.WriteLine(text.Value);
}

もちろん、XDocumentにも応用可能。

Advent LINQ (4): Buffering

列挙子を非同期で実行して、可能なら結果をキューに蓄積したい場合がある。列挙子の要素生成速度が十分に早ければ、並列実行出来ることになる。
並列実行コレクションに、丁度この目的に使えるBlockingCollectionクラスがある。

public static class LinqExtensions
{
    public static IEnumerable<T> Buffering<T>(this IEnumerable<T> enumerable, int queueCount = 10)
    {
        var queue = new BlockingCollection<T>(queueCount);
        Task.Factory.StartNew(() =>
            {
                try
                {
                    foreach (var value in enumerable)
                    {
                        queue.Add(value);
                    }
                }
                finally
                {
                    queue.CompleteAdding();
                }
            });

        return queue.GetConsumingEnumerable();
    }
}

使うときは、非同期化したい列挙子の直後に指定するだけだ。

var r = new Random();
foreach (var value in
    Enumerable.Range(0, 1000000).
    Select(index => r.Next()).
    Buffering(1000))
{
    Console.WriteLine(value);
}

これで、乱数の生成は最大1000個まで非同期で実行されてバッファリングされる。コンシューマー側(foreach)の処理が遅く、乱数の生成が早ければ、効率よく動作する。

Advent LINQ (3): FilesAsEnumerable

Directory.GetFilesが配列を返すので、列挙子にしてみる。特にSearchOption.AllDirectoriesが指定されると、処理が返って来るまでにかなり時間がかかることがある。
フォルダーは再帰探索する必要があるので、実装は少し捻る必要がある。

public static class LinqExtensions
{
    public static IEnumerable<string> FilesAsEnumerable(string path, string patterns)
    {
        foreach (var directoryPath in
            Directory.GetDirectories(path, "*", SearchOption.TopDirectoryOnly))
        {
            foreach (var filePath in
                FilesAsEnumerable(directoryPath, patterns))
            {
                yield return filePath;
            }
        }

        foreach (var filePath in
            Directory.GetFiles(path, patterns, SearchOption.TopDirectoryOnly))
        {
            yield return filePath;
        }
    }
}

これでもいいのだが、foreachだらけで見通しが悪い。全てクエリに置き換える。

public static class LinqExtensions
{
    public static IEnumerable<string> FilesAsEnumerable(string path, string patterns)
    {
        var byDirectory =
            from directoryPath in Directory.GetDirectories(path, "*", SearchOption.TopDirectoryOnly)
            from filePath in FilesAsEnumerable(directoryPath, patterns)
            select filePath;

        var byFile =
            from filePath in Directory.GetFiles(path, patterns, SearchOption.TopDirectoryOnly)
            select filePath;

        return byDirectory.Concat(byFile);
    }
}

これでファイルの列挙は以下のように記述できる。

foreach (var path in LinqExtensions.FilesAsEnumerable(@"C:project", "*.cs"))
{
    Console.WriteLine(path);
}

ファイルの逐次探索が可能になったので、大量のファイルをパイプライン処理できるようになった。
なお、このコードは.NET 4.0以降では、Directory.EnumerateFilesとほぼ同じ動作となる。

Advent LINQ (2): TextReader.AsEnumerable

TextReaderで読み取れる行を、列挙子に変えてみる。

public static class LinqExtensions
{
    public static IEnumerable<string> AsEnumerable(this TextReader tr)
    {
        try
        {
            while (true)
            {
                var line = tr.ReadLine();
                if (line == null)
                {
                    break;
                }
                yield return line;
            }
        }
        finally
        {
            tr.Dispose();
        }
    }
}

TextReaderは全て読み取った時点でDisposeを呼び出している。こうする事で、

foreach (var line in File.OpenText("VeryLongTextFile.txt").AsEnumerable())
{
    Console.WriteLine(line);
}

という使い方をしても、列挙が終わればリーダーがクローズされるようになる。
一旦列挙子になってしまえば、

// フォルダ内のすべてのテキストファイルの、先頭・終端の空白を削除し、
// 空行や#で始まる行を除外して、ソートして表示
foreach (var line in
    from path in
        Directory.GetFiles(".", "*.txt", SearchOption.AllDirectories).
        AsParallel()
    from line in
        File.OpenText(path).
        AsEnumerable()
    let trim = line.Trim()
    where (trim.Length >= 1) && (trim.StartsWith("#") == false)
    orderby trim
    select trim)
{
    Console.WriteLine(line);
}

こんなことも自在に行える。

Advent LINQ (1): SplitAsEnumerable

System.Stringクラスには、Splitというメソッドがある。引数で指定された文字で文字列を分割するメソッドなのだが、返却される結果は配列になっている。配列と言う事は、非常に多くの文字列が分割されると、それだけメモリを消費する事になる。
そんなわけで、このメソッドを逐次実行可能な列挙子を返すLINQメソッドとして定義してみる。

public static class LinqExtensions
{
    public static IEnumerable<string> SplitAsEnumerable(this string target, params char[] separators)
    {
        var startIndex = 0;
        while (true)
        {
            var index = target.IndexOfAny(separators, startIndex);
            if (index == -1)
            {
                yield return target.Substring(startIndex);
                break;
            }

            yield return target.Substring(startIndex, index - startIndex);
            startIndex = index + 1;
        }
    }
}

System.String.Splitはインスタンスメンバーなので、Splitというメソッド名にすると呼び出せなくなってしまう。仕方がないので、SplitAsEnumerableという名前で定義した。

var veryLongStringValue = "...";
foreach (var value in veryLongStringValue.SplitAsEnumerable(','))
{
    Console.WriteLine("Value={0}", value);
}

これで逐次分割できるようになった。ロジックを拡張して、ダブルクオートで囲まれた文字列を切り出す等の応用が出来ると思う。

LINQは本当に強力だ (8) 何が並列化されるのか

並列化が簡単に出来る事がLINQのアドバンテージの一つなのだが、AsParallelを付ける際に、クエリのどこがどのように並列化されるのかが見えているだろうか?
AsParallelを付ければ並列化出来るというのは簡単すぎるため、意味のない適用や実害が生じる可能性もある。たとえば、以下のコードを考えてみる。

// 重複しない乱数列を生成する
var rand = new Random();
var captured = new ConcurrentDictionary<int, int>();
var results =
    from index in
        Enumerable.Range(0, 100000)
    let value = rand.Next(200000)
    where captured.TryAdd(value, index) // 辞書に値の追加を試み、成功すればtrue
    select value;

少し脱線するが、ConcurrentDictionaryはDictionaryのスレッドセーフバージョンだ。しかも、ロック競合が起きにくく、かつアトミック操作が可能なメソッドが追加されており、上記のようにLINQでも使いやすい(DictionaryクラスはTryGetValueがoutパラメータを使うので、LINQでは使いにくかった)。

さて、このコードは小さいので並列化にはあまり向いていないが、それは横に置いておき、並列化したいのはどこなのかを考える。
やろうとしていることは、10万回分の乱数を取得し、その中から重複する値を避けた乱数列の取得だ。もちろん、Distinctを使えばいいのだが、それも横に置いておく。
生成した乱数(value)を辞書に追加し、成功すれば(つまり辞書にその値が無く、追加された場合)に、その値がselectで射影される。この、乱数の生成と辞書への追加の試行部分を並列化出来れば、大半の処理が並列化出来たことになる。
クエリ構文で書いていることもあり、AsParallelを挿入すべき場所は一か所しかないが、取りあえず挿入する。

var rand = new Random();
var captured = new ConcurrentDictionary<int, int>();
var results =
    from index in
        Enumerable.Range(0, 100000).
        AsParallel()    // 並列化
    let value = rand.Next(200000)
    where captured.TryAdd(value, index)
    select value;

LINQ(とPLINQ)を書き始めて間もないと、このAsParallelによって何が並列化されるのか、誤解する事が多いようだ。上記の例では、10万までの数値を生成する「Range」が並列化され、それ以降の乱数生成や判定が並列化されることも期待しつつも、本当に並列化されるのか自信が持てないらしい。
(いや、自分も最初はそうだったから、そういうものだと思う :-)

最初に書いた通り、良くも悪くもAsParallelを付けるだけでよいというのが、この誤解の主原因なのだろう。
まず、クエリ構文をメソッド構文にする。

var rand = new Random();
var captured = new ConcurrentDictionary<int, int>();
var results =
    Enumerable.Range(0, 100000).
    AsParallel().
    Select(delegate(index)
        {
            return new
            {
                value = rand.Next(200000),
                index = index
            };
        }).
    Where(delegate(entry)
        {
            return captured.TryAdd(entry.value, entry.index)
        }).
    Select(delegate(entry)
        {
            return entry.value;
        });

意味もなくletを使ってしまったので面倒な事になっているが、前回見せたとおりindexとvalueを射影しているだけだ。肝心のAsParallelは、letを射影するSelectの手前に入っている。Range→AsParallel→Select→Where→Selectと、パイプライン結合されているのが分かる。

そう、パイプライン結合されているのだ、AsParallelも。

パイプライン結合を実現しているのは、IEnumerable<T>インターフェイスだ。クエリの末端(最後のSelect)が列挙されるとき、GetEnumeratorが呼び出される連鎖が起きることを述べた。AsParallelもまた、同じようにGetEnumeratorの呼び出しと、列挙の連鎖が発生する。ということは、AsParallelが動作に影響を与えられるのは、自分よりも上位のRangeだけ、と言う事になる。

え?そうなの?
いやいや、実はこれが判りにくい原因ではないかと思う。AsParallelよりも下位のパイプライン結合が並列化されるのだ。つまり、乱数の生成と辞書への追加が、期待通り並列化される。Rangeによるindexの生成は並列化「されない」。
どうしてこのようになるのだろうか?ここにクエリ構文を使いすぎたり、varを使いすぎたりする弊害がある。上記のコードをばらばらにし、.NET2.0的にしてみる。

IEnumerable<int> results0 =
    Enumerable.Range(0, 100000);

ParallelQuery<int> results1 =
    results0.AsParallel();

ParallelQuery<Tuple<int, int>> results2 =
    results1.Select(delegate(index)
        {
            return Tuple.Create(rand.Next(200000), index);
        });

ParallelQuery<Tuple<int, int>> results3 =
    results2.Where(delegate(entry)
        {
            return captured.TryAdd(entry.Item1, entry.Item2);
        });

ParallelQuery results4 =
    results3.Select(delegate(entry)
        {
            return entry.Item1;
        });

匿名クラスは表現できないので、Tupleに置き換えてある。AsParallelの戻り値の型は、実はIEnumerable<T>ではない。ParallelQuery<T>型なのだ。但し、ParallelQuery<T>はIEnumerable<T>を実装しているので、これを直接foreachなどで列挙することは可能だ。つまり、今まで通りIEnumerable<T>が返されると思っていても、表面上の違いは無いということだ。

しかし、このコードを見れば、なんとなく並列化される範囲が見えてくると思う。ParallelQueryによって管理されているクエリが並列化される。少し不思議なのは、IEnumerable<T>に対してSelectやWhereを呼び出した場合はIEnumerable<T>が返されるのに、ParallelQuery<T>に対してSelectやWhereを呼び出した場合は、ParallelQuery<T>が返されることだ。

これもそれほど大げさな仕掛けではない。IEnumerable<T>の場合は、SelectやWhereといった拡張メソッドは「Enumerable」クラスに定義されている。ParallelQuery<T>に対してSelect・Whereした場合は、「ParallelEnumerable」クラスの拡張メソッドが使用されるのだ。C#のコンパイラが、SelectやWhereメソッドの第一引数の型にもっとも一致する拡張メソッドを自動的に選択するため、この仕掛けが機能する。まるで、メソッドのオーバーライドを実装しているかのようだ。

では、いったいどこで並列実行のからくりが実現されるのだろうか? ParallelQuery(ParallelEnumerable)の実装は複雑だが、基本的な考え方は単純だ。ParallelQuery<T>の最下位でGetEnumeratorが呼び出されたときに、並列化が行われる。

IEnumerable<int> results0 =
    Enumerable.Range(0, 100000);

ParallelQuery<int> results1 =
    results0.AsParallel();

ParallelQuery<Tuple<int, int>> results2 =
    results1.Select(delegate(index)
        {
            return Tuple.Create(rand.Next(200000), index);
        });

ParallelQuery<Tuple<int, int>> results3 =
    results2.Where(delegate(entry)
        {
            return captured.TryAdd(entry.Item1, entry.Item2);
        });

ParallelQuery<int> results4 =
    results3.Select(delegate(entry)
        {
            return entry.Item1;
        });

// ParallelQuery.GetEnumerator()が呼び出される
foreach (var value in results4)
{
    Console.WriteLine(value);
}

最下位でGetEnumeratorが呼び出されると、スレッドプールからいくらかのスレッドを割り当て、各スレッドがParallelEnumerableの拡張メソッドで定義された演算を実行する。この部分は、従来のGetEnumeratorによる結合では実行できない。何故なら、IEnumeratorインターフェイスはマルチスレッドに対応していないからだ。必然的に、パイプラインで並列化演算が可能なのは、それぞれの演算が専用に設計された、ParallelEnumerableに定義された拡張メソッド群だけ、ということになる。

(もちろん、それらの拡張メソッドから呼び出されるデリゲートの実装は、いくらでも拡張可能だ。SelectやWhereのデリゲートの実装は、使う側が記述するのだから。)


AsParallelの実装とか、ParallelQueryEnumeratorの実装に興味がわくかもしれない。この部分はスレッドの割り当てやデータの分散と集約など、実際にはかなり複雑になっていると思われる。

しかし、注目する点はその部分ではなく :-) 、各ワーカースレッドが並列実行している演算の部分だ。Select→Where→Selectの部分が、スレッド毎に分散されている。パイプラインでAsParallelを適用してから、foreachで列挙されるまでに結合された演算子が並列実行される事が分かる。
そして、(当たり前ではあるが)foreachによるGetEnumeratorの呼び出しの後(foreachループ)は、並列化されていない。Console.WriteLineが並列実行されるわけではない事も分かる。ここが並列実行されたとすると、記述したとおりに実行されないわけだから、C#の構文的にも変だ。
また、AsParallelの上位も並列化されない。こちらも、IEnumeratorインターフェイスの構造に阻まれて、並列化させることはできない。

結局のところ、IEnumerable.GetEnumeratorの呼び出しによって、並列化の「壁」が出来上がるわけだ。私はこれを「ゲート」と勝手に呼んでいる。PLINQクエリが入り混じる場合、このゲートを意図的に作ってやることで、並列化されるクエリの範囲を自由にコントロールできる。

IEnumerable.GetEnumeratorを明示的に呼び出させる事が出来れば、このゲートを作ったことになる。つまり:

IEnumerable<int> results0 =
    Enumerable.Range(0, 100000);

ParallelQuery<int> results1 =
    results0.AsParallel();

ParallelQuery<Tuple<int, int>> results2 =
    results1.Select(delegate(index)
        {
            return Tuple.Create(rand.Next(200000), index);
        });

// results2の直後にゲート生成
IEnumerable<Tuple<int, int>> results3 =
    ((IEnumerable<Tuple<int, int>>)results2).Where(delegate(entry)
        {
            return captured.TryAdd(entry.Item1, entry.Item2);
        });

IEnumerable<int> results4 =
    results3.Select(delegate(entry)
        {
            return entry.Item1;
        });

ということだ。こうすれば、results2だけが並列化の対象となる。

で、キャストは面倒であるため、この目的のための「AsEnumerable」という拡張メソッドがある。内部の実装はキャストしているだけだ。PLINQ向けには「AsSequential」もあるが、これはAsEnumerableと全く変わらない。AsParallelの逆演算子のイメージで導入されたのだと思う。

LINQは本当に強力だ (7) 範囲変数と構文の選択

LINQのクエリ構文には、範囲変数と呼ばれている機能がある。
たとえば、文字列で与えられた半径と円周を対にしてみる。

string[] values = new string[] { "234.56", "456.78", "345.67", "123.45" };
IEnumerable<Tuple<double, double>> results =
    from value in values
    select Tuple.Create(double.Parse(value), double.Parse(value) * 2 * Math.PI);

これで正しい結果が得られるのだが、文字列の数値をdoubleにパースする操作は2回行われる。
CLRがとても賢くなったとしたら、double.Parseに副作用が存在しないことをJITで確認して、1回の呼び出しに変換するかもしれないが、あまり期待できない。
そこで、普通ならパースした結果をローカル変数に入れておき、使いまわしたりするのだが、このような目的のために、LINQには「範囲変数」が存在する。

string[] values = new string[] { "234.56", "456.78", "345.67", "123.45" };
IEnumerable<Tuple<double, double>> results =
    from value in values
    let parsed = double.Parse(value)    // 範囲変数parsedに代入
    select Tuple.Create(parsed, parsed * 2 * Math.PI);

これなら確実に1回だけパースを行う。その結果を、範囲変数”parsed”に代入し、後で何度でも使いまわす事が出来る。
そういう訳で、このletは実に使い出がある。というより、これが無いとかなり困ったことになる。
ここで、新たな「let」という予約語(厳密には予約語ではないが、ここでは詳しく述べない)が登場するのだが、最初に見たときは「何でvarにしなかったんだ?」と思った。

もはやvarについて知らない人はいないと思うが、

// 右辺から、文字列の配列って分かるよね?
var values = new string[] { "234.56", "456.78", "345.67", "123.45" };
// 右辺から、Tuple<double, double>の列挙子って分かるよね?
var results =
    from value in values
    let parsed = double.Parse(value)
    select Tuple.Create(parsed, parsed * 2 * Math.PI);

というように書き直せる。右辺の結果の型が明確であれば、左辺にわざわざ型名を書かなくても良いでしょうというのが一点。もう一点は連載でもちらりと述べたが、匿名クラスをローカル変数に保持するのに、型名が書けないからという理由だ。

さて、やっとvarについて書けた。以後は注目すべき点がない場合はvarで書く :-)
varの意味づけがイメージ出来るように、実際に匿名クラスを使ってみようか。

var values = new string[] { "234.56", "456.78", "345.67", "123.45" };
// 型名が無いから、書きようがない
var results =
    from value in values
    let parsed = double.Parse(value)
    select new  // 匿名クラスだから、型名はない
    {
        R = parsed,
        Length = parsed * 2 * Math.PI
    };

上記の「results」は、varでしか宣言出来ない。故にvarという予約語が必要になった訳だ。
で、範囲変数であるletも事情は同じだ。場合によっては、型名が書けない事がある。それならば、letという予約語を作るよりもvarが使えたほうが、覚える予約語が減って良いのではないか?

var values = new string[] { "234.56", "456.78", "345.67", "123.45" };
var results =
    from value in values
    var parsed = double.Parse(value)    // varでいいじゃん?(もちろんNG)
    select new
    {
        R = parsed,
        Length = parsed * 2 * Math.PI
    };

何故letが導入されたのかは明らかではないが、私が感じた理由を示そうと思う。
まず、上のコードのアウトプットを少し増やす。

var values = new string[] { "234.56", "456.78", "345.67", "123.45" };
var rand = new Random();
var results =
    from value in values
    let parsed = double.Parse(value)
    let randomValue = rand.Next((int)parsed)    // 乱数を生成する
    select new
    {
        R = parsed,
        Length = parsed * 2 * Math.PI,
        Rand = randomValue
    };

ちょっと強引な例だが、パースした数値の範囲の乱数を加えた。見ての通り、あらかじめ代入した範囲変数parsedを使って、別の範囲変数を生成することも出来る。この辺りは、通常のローカル変数の感覚と全く変わらない。次のようなコードを書くまでは:

var values = new string[] { "234.56", "456.78", "345.67", "123.45" };
var rand = new Random();
var results =
    from value in values
    let parsed = double.Parse(value)
    let randomValue = rand.Next((int)parsed)
    orderby parsed  // ちょっとソートしてみようか。
    select new
    {
        R = parsed,
        Length = parsed * 2 * Math.PI,
        Rand = randomValue
    };

私は、上記のselectまで書いたところで、「凍り付いた」。意味が分かるだろうか?
parsedの値で昇順にソートするために、orderbyを書いた。ここまでは良いのだが、その下のselectが一体どうなってしまうのか、非常に不安になった。もちろん、parsedは昇順に並び替えられた順序でやってくる(select句はSelect拡張メソッドに対応している。OrderByの列挙子で順番に値が渡される事を思い出そう)。
では、randomValueはどうなのか?

上のクエリ構文を見ていると、randomValueはorderbyとは関係がないように見える。実際、その下のselect句でrandomValueをそのまま使っている。と言うことは?ソートされるのはparsedの値だけで、randomValueはソートされずに渡されるのか?すると、組み合わせるべき結果がメチャメチャになってしまうのではないか?
(いやいやいや、GetEnumeratorで渡されるというインフラを無視して、ワープしたかのように別のルートで値を渡すなど不可能、不可能なハズだ…?)

もし、let句が無く、varと「書けた」としたら、益々この罠に掛かったことだろう。

翌々書いておく。「ローカル変数」と「範囲変数」は異なるのだ。異なるのだ。異なるのだ。
上記のクエリ構文をメソッド構文に置き換える事が出来るだろうか? この罠に掛かってからは、メソッド構文のイメージが全く掴めなかった。結局、ILSpyでコードを確認して、以下のように変換されることが分かった。

var values = new string[] { "234.56", "456.78", "345.67", "123.45" };
var rand = new Random();
var results =
    values.
    Select(delegate(value)
        {
            var parsed = double.Parse(value);
            return new  // 不可視の匿名クラス
            {
                parsed = parsed,    // let parsedに対応
                randomValue = rand.Next((int)parsed)    // let randomValueに対応
            };
        }).
    OrderBy(delegate(entry)
        {
            return entry.parsed;
        }).
    Select(delegate(entry)
        {
            return new
            {
                R = entry.parsed,
                Length = entry.parsed * 2 * Math.PI,
                Rand = entry.randomValue
            };
        });

letのあるべき部分に、上記のような不可視の匿名クラスが作られ、Selectで一旦射影される。つまり、parsedとrandomValueはその時点の値がセットとして扱われる。だから、直後のOrderByでソートされても、parsedとrandomValueが誤った組み合わせになる事は無い。最後のSelectまで、この組み合わせは維持される。

letとletの間にwhereを入れたりした場合でも、新たな不可視の匿名クラスが作られ、Selectで射影される。分離されたletが存在するだけ、匿名クラスが増えることになる。これはつまり、分離されたletが沢山あるほど、匿名クラスの種類が膨れ上がると言う事だ。まあ、それはまだいい(コンパイラが勝手にやっていることだから)が、その度にnewされるというのは気持ちの良いことではない。この匿名クラスの生成とnewはかなりパターン化していると思われるので、ひょっとするとJITは何か工夫するかもしれない。

実際、範囲変数を正しく振る舞わせる為には、上記のようにするしかないだろう。で、メソッド構文が最も正確にLINQを表現できるとしても、上記のようなコードを書くのは面倒だ。やはりletが使えるクエリ構文が手放せない。私はいつも直感で書くのであまり注意していなかったが、自分でパターンを考えてみると、基本はクエリ構文で書き、letが不要な場合にはメソッド構文を選択しているようだ。

しかし、クエリ構文で書くデメリットもある。以前に述べた拡張メソッドとの相性の問題もあるが、クエリに問題があった場合に追跡が面倒だ。メソッド構文で書いていると、適当な場所でパイプラインをぶった切って、ToList()をかますことで、そこに流れたデータを直接デバッガで可視化出来る(データ量が多いとこの方法は使えないが、デバッグ時だから与えるデータを工夫すればいい)。

クエリ構文だと「ぶった切る」事が出来ない。letを使っているとクエリが複雑化しやすい上にこの制限があるのが辛い。
将来のVisual Studioでパイプラインの流れを可視化出来る何かが追加されると、非常に強力なのだが…
#使った事は無いが、Resharperはクエリ構文をリファクタリングで分割出来るようだ。さすがに強力。