VB.Net
LINQ
関数型プログラミング

[VB.Net]LINQを使って文字列とかをいじってみる

More than 1 year has passed since last update.

はじめに

なんかLINQって難しそうだなぁと思っていませんか? VB.Netユーザーの皆さん、もっと気軽に使ってみましょう!
この記事では、文字列をToCharArray()で文字型配列に分解してLINQで処理することで、「あ、LINQって意外と簡単じゃん!」て思って貰う事を目標とします。あとで、数値配列をいじったり、Aggregateで集計したりもします。

まずは普通の関数として書いてみる

まずは、LINQを使わない、普通の関数から始めてみましょう。

Form1.vb
    '文字列から数字だけを抜き出す関数
    Private Function RemoveNonNumericChars(source As String) As String
        '空っぽの文字列から始めて
        Dim result As String = String.Empty

        '引数の文字列を1文字ずつチェックしながら
        For Each ch As Char In source.ToCharArray()
            If Char.IsDigit(ch) Then
                '数字だったら繋ぎ合わせていく。
                result = result + ch
            End If
        Next

        '最後に繋いだ結果を返す
        Return result

    End Function


    Private Sub Button1_Click(sender As System.Object, e As System.EventArgs) Handles Button1.Click

        Dim str = "tel:0120-444-444"
        Dim numericStr = RemoveNonNumericChars(str)
        Debug.Print("numericStr = " + numericStr)

    End Sub

どういう処理かというと、文字列を与えると、その文字列を「Char型の配列」に変換(ToCharArray()で変換できます)して、1文字ずつFor Eachでループを回しながら、Char.IsDigit(ch)を使って「それが数字の文字かどうか」を判定し、数字文字ならば結果文字列に繋いでいく、というものです。"a1b2c3"ならば、'a'や'b','c'が弾かれて、"123"が返ってくる感じです。

LINQで置き換えてみる

これをLINQで置き換えると、こんな感じになります。1行目でLinqをImportsするのを忘れないようにしてください。

Form1.vb
Imports System.Linq

Public Class Form1

    Private Sub Button2_Click(sender As System.Object, e As System.EventArgs) Handles Button2.Click
        Dim str = "tel:0120-444-444"

        'LINQで同じ処理を書いた
        Dim numericStr = source.ToCharArray().Where(Function(ch) Char.IsDigit(ch)).Aggregate(String.Empty, Function(result As String, ch As Char) result + ch)

        Debug.Print("numericStr = " + numericStr)
    End Sub

End Class

WhereメソッドとAggregateメソッドをチェーンのように繋げています。こういうものをメソッドチェーンと呼びます。
下のように、ドット部分でメソッドチェーンに改行を入れると少し見やすくなるかもしれません。

Form1.vb
    Private Sub Button2_Click(sender As System.Object, e As System.EventArgs) Handles Button2.Click
        Dim str = "tel:0120-444-444"

        'LINQで同じ処理を書いた
        Dim numericStr = source.ToCharArray().
                Where(Function(ch) Char.IsDigit(ch)).
                Aggregate(String.Empty, Function(result As String, ch As Char) result + ch)

        Debug.Print("numericStr = " + numericStr)
    End Sub

LINQの部分をひとつひとつ見て行きましょう。

LINQ
source.ToCharArray().

この部分はLINQではないのでわかると思います。文字列を、文字型の配列 Char() に変換しています。LINQは配列やList、HashTable、Dictionary等の、コレクション型を処理する為のライブラリですので、文字列型を一旦、1文字単位で処理できる「文字列型の配列」に変換しているわけです。

戻り値はこんな感じになります。

ToCharArray()の戻り値
{"t"c, "e"c, "l"c, ":"c, "0"c, "1"c, "2"c, "0"c, "-"c, "4"c, "4"c, "4"c, "-"c, "4"c, "4"c, "4c"}

Where

この戻り値に対して、次のWhereメソッドを呼び出しています。

LINQ-Where
Where(Function(ch) Char.IsDigit(ch)).

Char()型の配列に対してWhereメソッドを呼んでいます。Whereでは、コレクションや配列を、指定した条件で絞り込む事ができます。引数として与えるのは、「条件判定関数」です。配列の各要素、ここでは1文字分のデータを引数として受け取って、それが条件に合う文字ならば True、合わない文字ならば Falseを返す、という関数が、今回の条件判定関数になります。

具体的には、以下の部分が条件判定関数です。

Whereメソッドに与えている単一行の無名関数(ラムダ式)
Function(ch) Char.IsDigit(ch)

chはChar型の引数です。今回処理するのがChar型の配列なので、条件判定関数の引数もChar型になります。そのchを、Char.IsDigit(Char) を使って「数字かどうか」を判定しています。戻り値はそのままTrue/Falseになります。単一行なのでEnd Functionがありませんが、複数行にしてももちろん大丈夫です。

Whereメソッドに与えている無名関数(複数行の場合)
Function(ch) 
    Dim result As Boolean = Char.IsDigit(ch)
    Return result
End Function    

複数行の場合はReturnが必要になるのでご注意下さい。
Whereメソッドは、配列の各要素(1文字ずつのデータ)に対してこの無名関数を呼び出し、結果がTrueのものだけを残した配列を新たに作ります

Whereメソッドの戻り値
{ "0"c, "1"c, "2"c, "0"c, "4"c, "4"c, "4"c, "4"c, "4"c, "4c"}

数字文字以外は綺麗に除去されています。Whereメソッドらくちん!

Aggregate

次は、これを結果文字列に連結していく部分です。これはAggregateメソッドを使います。
他の関数型言語を学んだ事がある方ならば、これは「fold(畳み込み関数)」だと言えば、分かりやすいでしょうか。
ちょっと複雑なのでゆっくり見て行きましょう。

LINQ-Aggregate
Aggregate(String.Empty, Function(result As String, ch As Char) result + ch)

Aggregateメソッドは、配列/コレクションのデータを、単一のデータに「集計」します。その為に、まず resultに初期値(ここでは String.Empty を初期値に指定しています)を設定し、配列の最初の要素から順番に1つずつ、resultに対して「適用」していきます。ここではその「適用」処理が、Aggregateメソッドの第二引数として与えている無名関数になります。なお、Aggregateメソッドの第一引数 String.Empty は、result の初期値です。

無名関数だけを抜き出してみましょう。内容は、文字列型のresultに、引数のchを連結して新しい文字列として返す、というものです。

Aggregateメソッドに与えている単一行の無名関数(ラムダ式)
Function(result As String, ch As Char) result + ch

Aggregateメソッドは、まず resultに 初期値 String.Emptyを設定してから、この無名関数に result と 配列の第一要素を入れて呼び出します。無名関数を仮にagg、Aggregateメソッドに渡される配列データをarrとして、処理のイメージを見てみましょう。

Aggreageteの処理イメージ
Dim result = String.Empty
dim ch = arr(0)
result = agg(result, ch)

この結果、resultには配列の第一要素(0番目の要素)が文字列として入っています。Aggregateメソッドは、そのまま次の要素(1番目の要素)を取得し、続けてaggを呼び出してresultに格納します。

Aggreageteの処理イメージ(続き)
ch = arr(1)
result = agg(result, ch)

そして、2番目の要素、3番目の要素…と、次々と呼び出してはresultに格納していきます。

Aggreageteの処理イメージ(続き2)
ch = arr(2)
result = agg(result, ch)

ch = arr(3)
result = agg(result, ch)

ch = arr(4)
result = agg(result, ch)

結果的にresultには、全ての文字型配列の要素が、1つの文字列として連結されたものが格納されています。

これが、Aggregateメソッドが内部で行っていることです。プログラマーが与えなければならないのは、resultの初期値と、そのresultに対して各要素をどのように「適用」していくのか、という処理を定義した無名関数のみです。

Aggregateの戻り値は、指定した無名関数の戻り値の型と同じになります。今回は文字列を返しているので文字列型となっていますが、別に文字列型を返さずとも、各CharのASCII値を合計したInteger型でも構わないのです。

このように、配列やコレクションの各要素を順番に同じ方法で処理していき、単一の値を作り出すことを「畳み込み」と呼びます。他の関数型言語ではfoldという名前の関数名になっている事が多いようなので、覚えておくと良いかと思います。

これらを全て繋げると、冒頭のLINQになるわけです。

LINQ
Dim numericStr = source.ToCharArray().Where(Function(ch) Char.IsDigit(ch)).Aggregate(String.Empty, Function(result As String, ch As Char) result + ch)

数値配列をWhereで絞り込む

Aggregateは慣れるまでちょっと考え方が難しいのですが、配列データをWhereメソッドで絞り込む、というだけなら、充分使えそうな気がしませんか?

Integer型の配列を使った、簡単な例を少し見てみましょう。

偶数だけを抜き出すLINQ
Dim arr() As Integer = {1, 2, 3, 4, 5, 6, 7, 8, 9}

'偶数だけを抜き出す
Dim even() As Integer = arr.Where(Function(item) item Mod 2 = 0).ToArray()

For Each i In even
    Debug.Print(i.ToString()) '2 4 6 8 が出力される
Next

Whereメソッドに与えている無名関数の意味は、分かりますよね? i Mod 2 = 0 は、i が偶数の場合のみ Trueが返る式なので、結果的に偶数だけが抜き出される、という訳です。
今回はAggregateで単一データにしていないので、最後はToArray()で配列として取得する必要があります。

クエリ式

ちなみに、これを「クエリ式」と呼ばれる、SQLのようなLINQの為の記法で書き表すと、次のような感じになります。

クエリ式で書いた場合
'奇数だけ抜き出す
Dim odd = From item In arr Where item Mod 2 = 1
For Each i In odd
    Debug.Print(i.ToString())
Next

今度は奇数を抜き出す処理にしてみました。「Function」とかの余計な装飾を全部省略できるので楽ちんですね!
尚、クエリ式の戻り値はIEnumerable型で、上の例でいうとoddは配列ではありません。まだクエリ式自体、実行されておらず、「関数」の形のまま保持されている状態です。For Each等で1つずつ値を取り出すと、実際に実行されて結果が取得できます。もしまとめて配列で取得したい場合は、odd.ToArray() で取得可能です。

Aggregateをクエリ式で使う

ついでですので、Aggregateをクエリ式で使う場合のお話も少し。クエリ式でAggregateを使う場合は、基本的には、既に用意されている集計用の関数を用いる使い方になります。用意されているのは、数値型を合計する Sum() や、数値型の最大数・最小数を求める Max()Min()、平均を求める Average() 等です。

こんな感じで使えます。

LINQ-Aggregate
    Private Sub Button4_Click(sender As System.Object, e As System.EventArgs) Handles Button4.Click
        Dim arr() As Integer = {1, 2, 3, 4, 5, 6, 7, 8, 9}

        '合計
        Dim iSum As Integer = Aggregate item In arr Where item > 7 Into Sum()
        Debug.Print("Sum = " + iSum.ToString()) ' Sum = 17

        '最大
        Dim iMax As Integer = Aggregate item In arr Where item < 4 Into Max()
        Debug.Print("Max = " + iMax.ToString()) ' Max = 3

        '最少
        Dim iMin As Integer = Aggregate item In arr Where item < 4 Into Min()
        Debug.Print("Min = " + iMin.ToString()) ' Min = 1

        '平均
        Dim dblAverage As Double = Aggregate item In arr Where item > 5 Into Average()
        Debug.Print("Average = " + dblAverage.ToString()) ' Average = 3

    End Sub

ちょっとした合計を求めたい時などには便利かもしれませんね…と言いたいところなのですが、実は配列自体にSum()やMax()、Average()などのメソッドがありますので、そんなに出番はないかもしれないですね(汗)
でも、仕組みとして知っておくと、いろいろと応用可能だと思います。

クイズ

最後に、この記事の総括として、「文字列の配列から、長さが5文字以下のものを削除する」という処理を、LINQを使ってシンプルに書いてみましょう。クエリ式じゃなくて普通のメソッド呼び出し形式で良いですよ。

…どうですか? イメージできましたか?

それでは、答えです。

Form1.vb
    Private Sub Button6_Click(sender As System.Object, e As System.EventArgs) Handles Button6.Click

        Dim strarr() As String = {"apple", "banana", "", "pine", "", "orange"}
        Dim result() = strarr.Where(Function(s) s.Length > 5).ToArray()

        result.ToList().ForEach(Sub(s) Debug.Print(s)) 'banana orange が出力される

    End Sub

イメージと合っていたでしょうか?
最後の1行は、おまけです。ForEachという、戻り値の無い無名Subプロシージャを引数に取るメソッドで、配列やコレクションの各要素についてそのSubプロシージャを実行してくれます。ForEachは、LINQのメソッドチェーンの最後につけて結果を出力したりできて便利なので、覚えておくと良いでしょう。

あとがき

先日、VB.Netの仕事でLINQを使ったら、「LINQは使っていないので使わないでください」と言われました。こういう記事を書く事で、VB.Netユーザーの皆様が、過去の呪縛から解き放たれる日が来ることを願っています。