はじめに
ファイル読み込み処理を例に、長い繰り返し処理を複数のメソッドに分割する方法を説明します。
「yield return」の説明が主なので拡張メソッドやLinqの話はあまりでてきません。ご容赦ください。
リファクタリング手順
- 長い繰り返し文を複数の短い繰り返し文に分割する
- 分割したそれぞれの繰り返し文をIEnumerable<T>を返すメソッドに抽出する(適宜)
リファクタリング前
using System;
using System.IO;
using System.Text;
namespace yyyiiieee
{
class Program
{
static void Main(string[] args)
{
using (var sr = new StreamReader("abcdef.log", Encoding.GetEncoding("Shift_JIS")))
{
while (sr.Peek() > -1)
{
// 一行読み込み
var strLine = sr.ReadLine();
// 内容によって処理を行なうかの判定を行なう
if (strLine == "Fatal")
{
Console.WriteLine("致命的なエラーです。");
break;
}
if (strLine == "Error")
{
Console.WriteLine("エラーです。");
continue;
}
// 一行ごとに行われる何らかの処理
var printer = new InfoPrinter(strLine);
printer.print();
}
}
}
}
}
上記コードを元にリファクタリングをしていきます。このコードはテキストファイルを一行づつ読み込み、読み込んだ行ごとに何らかの処理を行なうプログラムです。InfoPrinterは何らかの処理を表すために作った架空のクラスです。
この程度ではリファクタリングは必要ではないのでは?と思われるかもしれませんが、現実のプログラムではもっと複雑なことが行われており、これはそれを単純化しただけのものだとお考えください。
While文の中ではファイル読み込み、フィルタリング、一行ごとの処理の3種類のことを行っています。私は一つの繰り返し文の中では一つのことだけをやらせたいのでまずこの繰り返し文を3つに分割したいと思います。
リファクタリング途中
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace yyyiiieee
{
class Program
{
static void Main(string[] args)
{
// 読み込み部分
var strList = new List<string>();
using (var sr = new StreamReader("abcdef.log", Encoding.GetEncoding("Shift_JIS")))
{
while (sr.Peek() > -1)
{
var strLine = sr.ReadLine();
strList.Add(strLine);
}
}
// フィルタ部分
var strList2 = new List<string>();
foreach (var strLine in strList)
{
if (strLine == "Fatal")
{
Console.WriteLine("致命的なエラーです。");
break;
}
if (strLine == "Error")
{
Console.WriteLine("エラーです。");
continue;
}
strList2.Add(strLine);
}
// 終端部分
foreach (var strLine in strList2)
{
// 一行ごとに行われる何らかの処理
var printer = new InfoPrinter(strLine);
printer.print();
}
}
}
}
最初のwhile文を3分割するとこのようなコードとなります。リファクタリング前と違いファイルをすべて読み込んでから処理するようになりました。「ファイルサイズが10ギガあったらどうするんだ?」と思われるかもしれませんがメモリ管理のことはいったん忘れてください、後述します。
ここではWhile文とforeach文を用いていますが、最初の繰り返し文はなんでも構いません。2つ目以降の繰り返し文はforeachを用いたほうが後々楽になります。
リファクタリング後
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace yyyiiieee
{
class Program
{
/// <summary>
/// 本体
/// </summary>
static void Main(string[] args)
{
// 読み込み部分
IEnumerable<string> strList = CreateSequence();
// フィルタ部分
IEnumerable<string> strList2 = StrFilter(strList);
// 終端部分
PrintForAll(strList2);
}
private static IEnumerable<string> CreateSequence()
{
using (var sr = new StreamReader("abcdef.log", Encoding.GetEncoding("Shift_JIS")))
{
while (sr.Peek() > -1)
{
yield return sr.ReadLine();
}
}
}
private static IEnumerable<string> StrFilter(IEnumerable<string> strList)
{
foreach (var strLine in strList)
{
if (strLine == "Fatal")
{
Console.WriteLine("致命的なエラーです。");
yield break;
}
if (strLine == "Error")
{
Console.WriteLine("エラーです。");
continue;
}
yield return strLine;
}
}
private static void PrintForAll(IEnumerable<string> strList2)
{
foreach (var strLine in strList2)
{
// 一行ごとに行われる何らかの処理
var printer = new InfoPrinter(strLine);
printer.print();
}
}
}
}
つづけてメソッド抽出を行います。ここで重要なのはメソッドの戻り値に__IEnumerable<string>__を用いること、リストの各要素を返す際に__yield return 変数;__と書くこと、の2つです。詳しい文法はMSDNのリファレンスに書いてあります。
yield (C# リファレンス)
https://msdn.microsoft.com/ja-jp/library/9k7k7cf0.aspx
メソッドの引数としてIEnumerable<string>を持つ場合、foreachループを用いて要素を取り出す必要があります。
戻り値として要素を返す際に、返したくない要素がある場合は__continue;__を、メソッドを途中で中断したいときは__yield break;__を用います。
メモリ使用量
10MBのテキストファイルを処理した後のプロセスメモリ使用量を測定しました。
リファクタリング前 | リファクタリング途中 | リファクタリング後 |
---|---|---|
16.7MB | 44.0MB | 16.7MB |
"リファクタリング途中のプログラム"は読み込んだすべての文字列を一旦メモリ上に置くだけあって、メモリ使用量が多いことがわかります。一方"リファクタリング後のプログラム"はそれほどメモリ使用量が多くありません。何故でしょうか?
yeid return が実行されると一度処理が中断され次のメソッドに処理が移ります。再び中断されたメソッドが呼び出された場合は最後に実行されたyeid returnの次の行から再開されます。そのため、今回のCreateSequenceメソッドは実行されてもテキストファイル全てがメモリに格納されるわけではありません。一要素づつ処理が行われ、処理順序がリファクタリング前とほとんど同じになります。
またCreateSequenceメソッド呼び出し時はファイル読み込み処理が行われず、実際の処理が行われるのはPrintForAllメソッド呼び出し時です。呼び出しはforeachループでIEnumerable<string>が参照されたときに発生します。
注意点
何回も終端部分に当たる処理を呼び出す場合は、途中結果をメモリ上にキャッシュしておいたほうが良い場合もあります。例えば以下のようにPrintForAllを呼び出す場合、CreateSequenceメソッド、StrFilterメソッドはそれぞれ5回実行されます。これを防ぐには、StrFilterメソッドでyeid returnを使わずにただのListにして返す、LinqのToListメソッドを用いる、System.InteractiveというパッケージのMemoizeメソッドを用いると言った方法が考えられます。
class Program
{
/// <summary>
/// 本体
/// </summary>
static void Main(string[] args)
{
// 読み込み部分
IEnumerable<string> strList = CreateSequence();
// フィルタ部分
IEnumerable<string> strList2 = StrFilter(strList).Tolist;
// 終端部分
PrintForAll(strList2);
PrintForAll(strList2);
PrintForAll(strList2);
PrintForAll(strList2);
PrintForAll(strList2);
}
}
まとめ
これでメモリの制約が発生しそうな状況でも繰り返し文のメソッド分割ができるようになりました。
言わずもがなですがLinqと組み合わせることでさらに処理を簡単に書けそうですね。
おまけ
IEnumerable<T>を返すメソッドは繰り返し文を含んでいなくても構いません。例えば以下のようなメソッドでも大丈夫です。このメソッドは1回目の呼び出しの際にストリームのオープンを行い、3回目の呼び出しの際にストリームのクローズをして終了します。
private IEnumerable<string> CreateSequence()
{
var sr = new StreamReader("abcdef.log", Encoding.GetEncoding("Shift_JIS"))
yield return sr.ReadLine();
yield return sr.ReadLine();
sr.Close();
}