C# 7のローカル関数の使いどころとは?.NET TIPS

C# 7の新機能「ローカル関数」はどんなときに、どのように使えばよいのか。その基本的な使い方や、副次的なメリット、ラムダ式との違いなどを解説する。

» 2018年04月04日 05時00分 公開
[山本康彦BluewaterSoft/Microsoft MVP for Windows Development]
「.NET TIPS」のインデックス

連載目次

 C# 7(Visual Studio 2017)の新機能にローカル関数がある。ざっくり言ってしまえば「ローカル関数とはメソッドの中に書くメソッド」なのだが、どんなときに使えばよいのだろうか? 本稿では、その使いどころを紹介する。

POINT ローカル関数の使いどころ

ローカル関数の使いどころまとめ ローカル関数の使いどころまとめ


 特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。

 なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2017以降が必要である。サンプルコードはコンソールアプリの一部であり、コードの冒頭に以下の宣言が必要となる。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using static System.Console;

本稿のサンプルコードに必要な宣言(C#)

ローカル関数の使いどころ

 もちろんローカル関数が書けるところならどのように使ってもよいのだが、使うべき場面の筆頭は「隠蔽(いんぺい)したいとき」である。

 話を進める前に変数について考えてみよう。C#では、あるクラスのメンバ変数だけを使ってもプログラミングは可能なのに、なぜローカル変数を使うのだろう。それは、「変数のスコープをメソッド内(あるいはブロック内)に限定する」ことで、分かりやすく、「内部的にしか使用しない変数の値を必要以上に外部に公開することがない」コードになるからだ。これと同じように、ローカル関数のスコープはその親メソッド内(あるいはブロック内)に限定されるので、分かりやすく、内部的にしか行わない処理を必要以上に外部に公開しないコードになるはずである。

 例としてローカル関数を使っていない次のようなコードを考えてみよう。このIsMultipleOfメソッドを呼び出しているのは、FizzBuzzメソッドだけであるとする。しかし、IsMultipleOfメソッドはクラス内のどこからでも呼び出せるので、(将来の)安全を期して引数チェックのコードを書いている。

static string FizzBuzz(int n)
{
  if (n <= 0)
    throw new ArgumentOutOfRangeException("……省略……");

  if (IsMultipleOf(n, 3) && IsMultipleOf(n, 5)) return "Fizz Buzz";
  if (IsMultipleOf(n, 3)) return "Fizz";
  if (IsMultipleOf(n, 5)) return "Buzz";
  return n.ToString();
}

static bool IsMultipleOf(int n, int mod)
{
  // どこから呼び出されるか分からないので、引数をチェックしている
  if (mod == 0)
    throw new DivideByZeroException("……省略……");

  return n % mod == 0;
}

従来の書き方(C#)

 このIsMultipleOfメソッドを、FizzBuzzメソッドのローカル関数にしてみる(次のコード)。FizzBuzzメソッド以外から呼び出される心配はなくなったので、安心して引数チェックを省略できる。また、副次的な効果として引数が1つ不要にもなっている。

static string FizzBuzz(int n)
{
  if (n <= 0)
    throw new ArgumentOutOfRangeException("……省略……");

  if (IsMultipleOf(3) && IsMultipleOf(5)) return "Fizz Buzz";
  if (IsMultipleOf(3)) return "Fizz";
  if (IsMultipleOf(5)) return "Buzz";
  return n.ToString();

  // ローカル関数
  // ・親メソッドから呼び出されるだけなので、この例では引数チェック不要
  // ・親メソッドのローカル変数にアクセスできるので、この例では引数nが不要
  bool IsMultipleOf(int mod)
  {
    return n % mod == 0;
  }
}

IsMultipleOfメソッドをローカル関数にしてFizzBuzzメソッド内に隠蔽した(C#)

 ローカル関数の使いどころとしては、この他にメソッド内でのデリゲート定義や、yield returnするメソッドやasync/awaitを使うメソッドで分かりやすい例外を出すといった用途もある(後述する)。

ローカル関数の書き方

 ローカル関数の書き方は、従来のメソッドとだいたい同じだ。次のような相違点がある。

  • アクセス修飾子を付けられない:親のメソッドなどからしかアクセスできないので、publicやprotectedなどのアクセス修飾子を付ける意味がない
  • staticキーワードを付けられない:親のメソッドなどと同じになる
  • 属性を付けられない

 ローカル関数の記述は、メソッドだけでなくブロックを持っているメンバならどこにでも書ける。例えば次のようなメンバだ。

  • メソッドやコンストラクタ
  • プロパティのアクセサ
  • イベントのアクセサ
  • ステートメント形式のラムダ式
  • 他のローカル関数

 ローカル関数は、ローカル変数とは違って、定義する前でも参照できる(次のコード)。ローカル変数のスコープは宣言箇所からブロック末尾までだが、ローカル関数のスコープはブロック全体なのである。

static void SampleMethod01()
{
  // 参照箇所より先に定義したローカル関数
  int LocalFunc01(int a, int b)
    => a + b;

  // ここより前で定義されているローカル関数の呼び出し
  WriteLine($"LocalFunc01(1,2)={LocalFunc01(1,2)}");
  // 出力:LocalFunc01(1,2)=3

  // ここより後で定義されているローカル関数の呼び出し
  WriteLine($"LocalFunc02(2,3)={LocalFunc02(2,3)}");
  // 出力:LocalFunc02(2,3)=6

  // 後続のローカル関数の記述が長いときは、ここにreturnを明示するとよい
  // return

  // 参照箇所より後に定義したローカル関数
  int LocalFunc02(int m, int n)
    => m * n;
}

ローカル関数は定義の前でも参照できる(C#)
LocalFunc02ローカル関数は、その定義より前で呼び出しているが問題なく動作する。ローカル関数を末尾に置く場合、このように短いローカル関数なら見通しは悪くならない。長いローカル関数を末尾に置くときは、その直前にreturnステートメントを目立つように置くとよいだろう。それ以降の記述はローカル関数だけであると示すためだ。
なお、この例に示したように、(通常のメソッドなどと同じく)ローカル関数の本体をラムダ式で記述することもできる。

 ローカル関数を定義した位置から見える変数やメソッドなどに、ローカル関数内からアクセスできる(次のコード)。ラムダ式や匿名関数とも共通する性質であるが、このようなものをクロージャとも呼ぶ。

static void SampleMethod02()
{
  // ローカル変数a、bの宣言より前に定義したローカル関数
  // 変数a、bにアクセスできない(コンパイルエラー)
  //int LocalFunc01()
  //  => a + b;

  // ここでLocalFunc02を使うのはコンパイルエラー
  // LocalFunc02内でアクセスするローカル変数m、nが、ここでは未割り当てのため
  //WriteLine($"LocalFunc02()={LocalFunc02()}");

  int a = 1, b = 2;
  int m = 2, n = 3;

  // ローカル変数m、nへ値が割り当てられた後ならLocalFunc02が使える
  WriteLine($"LocalFunc02()={LocalFunc02()}");
  // 出力:LocalFunc02()=6

  // ローカル変数m、nの宣言より後に定義したローカル関数
  // 変数m、nにアクセスできる
  int LocalFunc02()
    => m * n;

  {
    // ブロック内で宣言したローカル変数p
    int p = 5;

    // このローカル関数からはpが見える
    int LocalFunc03()
      => p * p;

    WriteLine($"LocalFunc03()={LocalFunc03()}");
    // 出力:LocalFunc03()=25
  }

  // ブロック外のローカル関数からはpが見えない(コンパイルエラー)
  //int LocalFunc04()
  //  => p * p;
}

ローカル関数内部から親メソッドのローカル変数にアクセスする(C#)

 また、ローカル関数は、通常のメソッドと同様にデリゲートとしても扱える(次のコード)。

static void SampleMethod03()
{
  // ラムダ式でデリゲートを定義して使う従来の書き方
  Func<int, bool> IsOdd = (n) =>
    {
      return n % 2 != 0;
    };
  var odds = Enumerable.Range(1, 10).Where(IsOdd);
  WriteLine($"odds={string.Join(",", odds)}");
  // 出力:odds=1,3,5,7,9

  // ローカル関数も、通常のメソッドと同様にデリゲートとして扱える
  bool IsEven(int n)
  {
    return n % 2 == 0;
  }
  var evens = Enumerable.Range(1, 10).Where(IsEven);
  WriteLine($"evens={string.Join(",", evens)}");
  // 出力:evens=2,4,6,8,10
}

ローカル関数をデリゲートとして扱う(C#)
隠蔽するためにこれまでならラムダ式を使ってデリゲートを定義していたような場面で、ローカル関数が使える。

 ちなみに、本稿を執筆する際に使ったVisual Studio 2017 15.6.4では、上のコードのIsOddの定義部分で、ローカル関数を使うべきだという「電球」アイコンの警告が出た(次の画像)。

デリゲートの定義にもローカル関数が推奨されている デリゲートの定義にもローカル関数が推奨されている

ラムダ式では難しかったこと

 直前のサンプルコードなどを見ると、ローカル関数とラムダ式(あるいは匿名関数)は同じようなものに思えるかもしれない。ブロックだけでなく式の中にも書けるラムダ式の方が便利そうな気もするだろう。しかしローカル関数は、ラムダ式では難しかったりできなかったりした次のようなことも可能なのだ。

  • ラムダ式では、再帰呼び出しが難しい
  • ラムダ式では、イテレータ(yield returnするメソッド)が書けない
  • ラムダ式では、引数の既定値を与えられない(省略可能な引数にできない)
  • ラムダ式では、ジェネリックにできない
  1. iteratormethods#

yieldメソッドでの事前チェック

 ローカル関数の使いどころとして、隠蔽以外に副次的なメリットがあるケースを2つ紹介しよう。いずれも例外の出し方を分かりやすくするものだ。

 1つ目はイテレータメソッド、つまりyield returnで結果を順に返していくメソッドだ。

 イテレータメソッドの先頭で引数をチェックして、範囲外なら例外を出すものとしよう。従来の書き方では、範囲外の引数を渡したとき、例外が出るのは列挙を始めるときになってしまう(次のコード)。

// 1から引数upperまでの間で偶数を列挙するメソッド
static IEnumerable<int> Evens(int upper)
{
  // 引数チェック
  if (upper < 1)
    throw new ArgumentOutOfRangeException("……省略……");
    // この例外は、このEvensメソッドを呼び出したときに出てほしいのだが……

  foreach (int n in Enumerable.Range(1, upper))
    if (n % 2 == 0)
      yield return n;
}

static void Main(string[] args)
{
  // 適正な引数でEvensメソッドを呼び出し
  IEnumerable<int> evens1 = Evens(10);
  WriteLine($"evens={string.Join(",", evens1)}");
  // 出力:evens=2,4,6,8,10

  // 範囲外の引数でEvensメソッドを呼び出し
  IEnumerable<int> evens2 = Evens(-1); // ここでは例外が出ない
  try
  {
    foreach (int n in evens2) // 列挙を始めるときに例外が出る
      if (n > 5)
        WriteLine("5を超えた最初の数={n}");
  }
  catch { }

#if DEBUG
  ReadKey();
#endif
}

従来の書き方で引数チェックするイテレータメソッドを使う(C#)
このEvensメソッドは、冒頭で引数をチェックしているので、呼び出したときに(コード中「Evens(-1)」のところで)例外が出てほしい。実際には、その後のforeachループで列挙を始めるときに例外が出る。

 ローカル関数を使って引数チェックとyield returnする部分を分離すれば、期待通りにメソッド呼び出し時に例外が出るようになる(次のコード)。

// 1から引数upperまでの間で奇数を列挙するメソッド
static IEnumerable<int> Odds(int upper)
{
  // 引数チェック
  if (upper < 1)
    throw new ArgumentOutOfRangeException("……省略……");

  // ローカル関数呼び出し
  return LocalOdds();

  // yield returnする部分をローカル関数に分離
  IEnumerable<int> LocalOdds()
  {
    foreach (int n in Enumerable.Range(1, upper))
      if (n % 2 != 0)
        yield return n;
  }
}

static void Main(string[] args)
{
  // 適正な引数でOddsメソッドを呼び出し
  IEnumerable<int> odds1 = Odds(10);
  WriteLine($"odds={string.Join(",", odds1)}");
  // 出力:odds=1,3,5,7,9

  IEnumerable<int> odds2 = null;
  try
  {
    // 範囲外の引数でOddsメソッドを呼び出し
    odds2 = Odds(-1); // ここで例外が出る
  }
  catch { }
  if (odds2 != null)
    foreach (int n in odds2)
      if (n > 5)
        WriteLine("5を超えた最初の数={n}");

#if DEBUG
  ReadKey();
#endif
}

ローカル関数を使った書き方で引数チェックするイテレータメソッドを使う(C#)
Oddsメソッドの中でyield returnする部分をローカル関数にして、引数をチェックする部分と分離している。ローカル関数を呼び出す前に引数チェックが実行されるので、範囲外の引数を渡されたときにはOddsメソッドを呼び出した時点で例外が出る。

asyncメソッドでの事前チェック

 ローカル関数を使って例外の出し方を分かりやすくするケースの2つ目は非同期メソッド、つまりシグネチャにasyncが付いているメソッドだ。

 やはり非同期メソッドの先頭で引数をチェックして、範囲外なら例外を出すものとしよう。従来の書き方で非同期実行の終了を待機した場合、発生した例外はAggregateException例外(System名前空間)にラップされ、待機中に補足される(次のコード)。実際に発生した例外を知るにはそのInnerExceptionsプロパティの内容を列挙しなければならず、少々面倒なのだ。

// 指定されたURLのWebページからタイトルを取得するメソッド
static async Task<string> GetWebPageTitleAsync1(string url)
{
  // 引数チェック
  if (string.IsNullOrWhiteSpace(url))
    throw new ArgumentNullException();
  // まだ非同期実行を始めていないので、この例外はそのまますぐに出てほしいのだが……

  using (var webClient = new WebClient())
  {
    // 非同期実行
    string html = await webClient.DownloadStringTaskAsync(url);

    MatchCollection matches
      = Regex.Matches(html, @"\<title\>\s*(.+?)\s*\</title\>");
    return matches.OfType<Match>().FirstOrDefault()?.Groups[1].Value;
  }
}

static void Main(string[] args)
{
  // 適正な引数でGetWebPageTitleAsync1メソッドを呼び出し
  const string URL = "http://www.atmarkit.co.jp/ait/subtop/dotnet/";
  var task11 = GetWebPageTitleAsync1(URL);
  task11.Wait(); // 非同期実行の終了を待機
  string title11 = task11.Result; // 非同期実行の結果を取得
  WriteLine($"title={title11}");
  // 出力:title=Insider.NET : .NET ソリューションのための(後略)

  // 範囲外の引数でGetWebPageTitleAsync1メソッドを呼び出し
  var task12 = GetWebPageTitleAsync1(null); // ここでは例外が出ない
  try
  {
    // 非同期実行の待機中に例外が出る
    task12.Wait(); // 非同期実行の終了を待機
    string title12 = task12.Result; // 非同期実行の結果を取得
  }
  catch (AggregateException aex)
  {
    // 例外はAggregateExceptionにラップされて出てくる
    foreach (var ex in aex.InnerExceptions)
      WriteLine($"{ex.GetType().Name}: {ex.Message}");
    // 出力:ArgumentNullException: 値を Null にすることはできません。
  }

#if DEBUG
  ReadKey();
#endif
}

従来の書き方で引数チェックする非同期メソッドを使う(C#)
このGetWebPageTitleAsync1メソッドは、冒頭で引数をチェックしてArgumentNullException例外(System名前空間)を発生させている。まだ非同期実行を始める前なのだから、AggregateException例外にラップされることなく、即座にArgumentNullException例外のまま送出されてほしい。しかし実際にはラップされてしまい、非同期実行の待機中になってから出てくる。キャッチした側ではAggregateException例外のInnerExceptionsプロパティを列挙しなければならない。
なお、コード中「Regex.Matches」の部分については、.NET TIPS「正規表現を使ってパターンに一致する全ての文字列を抽出するには?[C#/VB]」を参照のこと。

 これも、ローカル関数を使って引数チェックと非同期実行する部分を分離すれば、期待通りにArgumentNullException例外がそのまま即座に出てくるようになる(次のコード)。

// 指定されたURLのWebページからタイトルを取得するメソッド
// (↓シグネチャにasyncは付けない)
static Task<string> GetWebPageTitleAsync2(string url)
{
  // 引数チェック
  if (string.IsNullOrWhiteSpace(url))
    throw new ArgumentNullException();

  // ローカル関数呼び出し
  return GetTitleAsync(); // awaitせず、Task<string>のまま返す

  // 非同期実行する部分をローカル関数に分離
  // (↓こちらのシグネチャにはasyncが必要)
  async Task<string> GetTitleAsync()
  {
    using (var webClient = new WebClient())
    {
      // 非同期実行
      string html = await webClient.DownloadStringTaskAsync(url);

      MatchCollection matches
        = Regex.Matches(html, @"\<title\>\s*(.+?)\s*\</title\>");
      return matches.OfType<Match>().FirstOrDefault()?.Groups[1].Value;
    }
  }
}

static void Main(string[] args)
{
  // 適正な引数でGetWebPageTitleAsync2メソッドを呼び出し
  const string URL = "http://www.atmarkit.co.jp/ait/subtop/dotnet/";
  var task21 = GetWebPageTitleAsync2(URL);
  task21.Wait();
  string title21 = task21.Result;
  WriteLine($"title={title21}");
  // 出力:title=Insider.NET : .NET ソリューションのための(後略)

  Task<string> task22 = null;
  try
  {
    // 範囲外の引数でGetWebPageTitleAsync2メソッドを呼び出し
    task22 = GetWebPageTitleAsync2(null);
    // メソッド呼び出し時に即座に例外が出る
  }
  catch (ArgumentNullException ex)
  {
    // ArgumentNullExceptionがそのまま出てくる
    WriteLine($"{ex.GetType().Name}: {ex.Message}");
    // 出力:ArgumentNullException: 値を Null にすることはできません。
  }
  if (task22 != null)
  {
    task22.Wait();
    string title22 = task22.Result;
  }

#if DEBUG
  ReadKey();
#endif
}

ローカル関数を使った書き方で引数チェックする非同期メソッドを使う(C#)
GetWebPageTitleAsync2メソッドの中で非同期実行する部分をローカル関数にして、引数をチェックする部分と分離している。それによって、GetWebPageTitleAsync2メソッド自体は非同期メソッドでなくせる(シグネチャからasyncを外せる)。asyncを付けない普通のメソッドになったので、引数チェックで投げた例外はそのまま即座に送出される。ローカル関数を呼び出すところにawaitを付けてしまうと(=GetWebPageTitleAsync2メソッド自体にasyncを付けたままだと)、元のコードと同様にAggregateException例外にラップされてしまうので気を付けよう。

まとめ

 ローカル関数の典型的な使いどころは、隠蔽したいときだ。すなわち、メソッドなどから一部のコードをメソッドとして切り出したいのだが、しかし他のメソッドなどからはアクセスさせたくないという場合である。また、メソッドなどの中でデリゲートを定義する代わりとしても推奨される。

「.NET TIPS」のインデックス

.NET TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。