CLR 徹底解剖

F# の基礎

Luke Hoban

F# は Microsoft .NET Framework 用の新しい関数型オブジェクト指向プログラミング言語で、今年リリースされる Microsoft Visual Studio 2010 に統合されます。F# は単純で簡潔な構文と厳密で静的な型指定を組み合わせた言語で、F# Interactive での簡易探究的プログラミングから、Visual Studio を使用した大規模な .NET Framework ベースのコンポーネント開発に至るまで広範なサポートを提供します。

F# は CLR 上で実行するように、土台から設計されています。F# は .NET Framework ベースの言語として、.NET Framework プラットフォームで使用可能な機能豊富なライブラリを活用しているため、.NET ライブラリの構築や .NET インターフェイスの実装に使用できます。また、ジェネリック、ガベージ コレクション、末尾呼び出し命令、基本的な共通言語基盤 (CLI) 型システムなど、数多くの中核となる CLR 機能も利用しています。

今回のコラムでは、F# 言語のいくつかの中核となる概念と、CLR の上位への実装について説明します。

F# の概要

では、まず F# の多くの中核言語機能について簡単に見ていきましょう。これらの機能に関する詳細、および F# 言語の他の多くの興味深い概念については、F# Developer Center (fsharp.net、英語) から入手できるドキュメントを参照してください。

F# の最も基本的な機能は let キーワードです。let キーワードは名前に値をバインドします。次のように、let を使用してデータ値と関数値の両方をバインドできます。また、最上位レベルのバインディングとローカル バインディングの両方に let を使用できます。

let data = 12
 
let f x = 
    let sum = x + 1 
    let g y = sum + y*y 
    g x

次のように、F# では、リスト、型指定された省略可能な値、組など、構造化データを操作するいくつかの中核となるデータ型と言語構文が用意されています。

let list1 = ["Bob"; "Jom"]

let option1 = Some("Bob")
let option2 = None

let tuple1 = (1, "one", '1')

F# のパターン マッチング式を使用すると、構造化データの一部を他のデータと照合することができます。パターン マッチングは、C 言語などの switch ステートメントに似ていますが、一部を照合したり、照合した式から一部を取り出したりといった、価値の高い方法を提供します。これは、次のように、パターン マッチング文字列に正規表現を使用する方法に若干似ています。

let person = Some ("Bob", 32)

match person with
| Some(name,age) -> printfn "We got %s, age %d" name age
| None -> printfn "Nope, got nobody"

F# では、さまざまなデータ ソースからデータにアクセスするといった多くのタスクに .NET Framework ライブラリを使用します。.NET ライブラリは、他の .NET 言語と同じ方法で F# でも使用できます。

let http url = 
    let req = WebRequest.Create(new Uri(url))
    let resp = req.GetResponse()
    let stream = resp.GetResponseStream()
    let reader = new StreamReader(stream)
    reader.ReadToEnd()

また、F# はオブジェクト指向言語のため、C# や Visual Basic と同様に、任意の .NET クラスまたは構造体を定義できます。

type Point2D(x,y) = 
    member this.X = x
    member this.Y = y
    member this.Magnitude = 
        x*x + y*y
    member this.Translate(dx, dy) = 
        new Point2D(x + dx, y + dy)

さらに、F# では、レコードと判別共用体という 2 つの特別な種類の型をサポートしています。レコードは、複数のデータ値を名前付きフィールドで表現するシンプルな表記です。判別共用体は、数多くの異なる種類 (各種類にさまざまな関連付けられたデータを含む) の値を保持できる型を表す表現力豊かな方法です。この例を次に示します。

type Person = 
    { Name : string;
      HomeTown : string;
      BirthDate : System.DateTime }

type Tree = 
    | Branch of Tree * Tree
    | Leaf of int

CLR 上の F#

F# には CLR のメタデータや中間言語 (IL) とは大きく異なる、型システム、構文、および言語構成要素が含まれていて、多くの点で C# よりもレベルの高い言語と言えます。これには、いくつかの興味深い意味合いがあります。最も重要なのは、多くの場合、F# 開発者は当面の問題領域により近い、より高いレベルで問題を解決し、プログラムについて考えることができるという点です。しかし、F# コンパイラが F# コードを CLR にマップする際に多くの作業を行うことから、マッピングが直接的ではないということも意味しています。

C# 1.0 コンパイラと CLR は同時期に開発されたため、両者の機能は密接に連携していました。ほぼすべての C# 1.0 言語構成要素には、CLR 型システムと CIL に非常に直接的な表現があります。C# 言語は CLR 自体よりも速く進化したので、これ以降にリリースされた C# では、直接的な関係が当てはまらなくなってきています。基本的な C# 2.0 言語機能の中でも反復子と匿名メソッドは、CLR に相当する機能がない言語機能です。C# 3.0 では、クエリ式と匿名型がこの傾向を受け継いでいます。

F# ではさらにもう一歩踏み込んでいます。多くの言語構成要素に IL に直接相当するものがないため、パターン マッチング式などの機能は、パターン マッチングを効率的に実現するために使用される機能豊富な一連の IL 命令にコンパイルされます。レコードやユニオンなどの F# 型では、必要な多くのメンバーが自動的に生成されます。

ただし、ここでは現在の F# コンパイラが使用するコンパイル技法について説明していることに注意してください。これらの実装に関する多くの詳細を F# 開発者が直接目にすることはなく、将来のバージョンの F# コンパイラではパフォーマンスの最適化や、新機能を有効にするために変更される可能性があります。

既定で不変

F# の基本の let によるバインドは C# の var に似ていますが、let でバインドされた名前の値を後から変更できないという 1 つの非常に大きな違いがあります。つまり、F# では既定で値が不変です。

let x = 5
x <- 6 // error: This value is not mutable

状態が不変であるということを使用すると、ロックについて考える必要がないため、同時実行に大きなメリットがあります。つまり、複数のスレッドから問題なくアクセスできます。また、不変性はコンポーネントどうしの結び付きを緩やかにする傾向があります。あるコンポーネントが別のコンポーネントに影響を与える唯一の方法は、そのコンポーネントへの明示的な呼び出しを行うことです。

次のように、可変性を F# に取り入れることができます。多くの場合、可変性は他の .NET ライブラリを呼び出すとき、または特定のコード パスを最適化するために使用されます。

let mutable y = 5
y <- 6

同様に、F# の型も既定で不変です。

let bob = { Name = "Bob"; 
            HomeTown = "Seattle" }
// error: This field is not mutable
bob.HomeTown <- "New York" 

let bobJr = { bob with HomeTown = "Seattle" }

この例のように、フィールドを変更できないときは、コピーと更新を使用して、古いコピーから新しいコピーを作成しながら 1 つ以上のフィールドを変更するのが一般的です。作成される新しいオブジェクトは、元のオブジェクトと多くの部分を共有します。この例では、"Bob" という文字列だけが必要です。こうした共有は、不変性のパフォーマンスの重要な要素です。

共有は F# のコレクションでも確認できます。たとえば、F# のリスト型は、次のように他のリストと末尾を共有できるリンク リスト形式のデータ構造です。

let list1 = [1;2;3]
let list2 = 0 :: list1
let list3 = List.tail list1

不変オブジェクトによるプログラミング固有のコピーと更新および共有のおかげで、こうしたプログラムのパフォーマンス プロファイルは、通常の命令型プログラムと大きく異なることがよくあります。

ここで、CLR が大きな役割を果たします。不変プログラミングでは、配置済みのデータを変更するのではなく、データを変換した結果として、短期間しか存在しないオブジェクトが作成されることが多くなります。CLR ガベージ コレクター (GC) は、このような状況に適切に対処します。短期間しか存在しない小さなオブジェクトは、CLR GC によって使用される世代別のマーク アンド スイープにより、相対的に低コストになります。

関数

F# は関数型言語です。当然ながら、言語全体において関数が重要な役割を果たします。関数は、F# 型システムの最も重要な部分です。たとえば、"char -> int" 型は、char を受け取って int を返す F# 関数を表します。

F# 関数は .NET のデリゲートに似ていますが、2 つの大きな違いがあります。まず、F# 関数は名目的ではありません。char を受け取って int を返すすべての関数は "char -> int" 型です。それに対して、デリゲートでは、このシグネチャの関数を表すために異なる名前を持つ複数のデリゲートが使用されることがあるため、相互に置き換えることはできません。

次に、F# 関数は、部分適用と完全適用を効率的にサポートするように設計されています。部分適用とは、複数のパラメーターを受け取る関数にパラメーターのサブセットしか指定されないことです。このため、残りのパラメーターを受け取る新しい関数になります。

let add x y = x + y

let add3a = add 3
let add3b y = add 3 y
let add3c = fun y -> add 3 y

すべての高機能 F# 関数の値は、F# ランタイム ライブラリの FSharp.Core.dll で定義されているように、FSharpFunc<, > 型のインスタンスです。C# で F# ライブラリを使用するときは、FSharpFunc<, > は、パラメーターとして受け取るか、メソッドから返されるすべての F# 関数の値に含まれる型になります。このクラスは、おおよそ次のようになります (C# でこのクラスを定義した場合)。

public abstract class FSharpFunc<T, TResult> {
    public abstract TResult Invoke(T arg);
}

特に、すべての F# 関数が基本的に 1 つの引数を受け取って、1 つの結果を生成することに注意してください。このことは、部分適用の考え方をうまく捉えています。つまり、複数のパラメーターを受け取る F# 関数は、実際には次のような型のインスタンスになります。

FSharpFunc<int, FSharpFunc<char, bool>>

つまり、int を受け取って別の関数を返します。返される関数自体は、char を受け取ってブール値を返します。一般的なケースの完全適用は、F# コア ライブラリの一連のヘルパー型を使用して高速に行われます。

ラムダ式 (fun キーワード) を使用して、または (既に説明した add3a の場合のように) 別の関数の部分適用の結果として、F# 関数値が作成されるときは、F# コンパイラによってクロージャ クラスが生成されます。

internal class Add3Closure : FSharpFunc<int, int> {
    public override int Invoke(int arg) {
        return arg + 3;
    }
}

これらのクロージャは、C# コンパイラと Visual Basic コンパイラによってラムダ式の構成要素に対して作成されるクロージャと同様です。クロージャは、CLR レベルを直接サポートしない .NET Framework プラットフォームでコンパイラによって生成される最も一般的な構成要素の 1 つです。ほぼすべての .NET プログラミング言語に存在し、特に F# では頻繁に使用されます。

関数オブジェクトは F# では一般的なので、F# コンパイラは多くの最適化技法を使用して、これらのクロージャを割り当てる必要がないようにします。可能な場合にインライン、ラムダ リフティング、.NET メソッドとしての直接表現を使用すると、F# コンパイラによって生成される内部コードが、ここで説明するコードとやや異なることがよくあります。

型の推論とジェネリック

ここまでに紹介したすべてのコード例の中で注目に値する特徴は、型の注釈がないことです。F# は静的に型指定されるプログラミング言語ですが、型の推論を幅広く活用するため、多くの場合、明示的な型の注釈を必要としません。

次の C# 3.0 コードに示すように、型の推論は、ローカル変数に対して型の推論を使用する C# 開発者や Visual Basic 開発者にとってはなじみがあります。

var name = "John";

F# の let キーワードも同様ですが、F# の型の推論はさらに優れていて、フィールド、パラメーター、および戻り値の型にも適用されます。次の例では、x と y という 2 つのフィールドが int 型を持つと推論されます。これは、型定義の本体内でこれらの値に対して使用される + 演算子と * 演算子の既定の推論です。Translate メソッドは、"Translate : int * int -> Point2D" という型を持つと推論されます。

type Point2D(x,y) = 
    member this.X = x
    member this.Y = y
    member this.Magnitude = 
        x*x + y*y
    member this.Translate(dx, dy) = 
        new Point2D(x + dx, y + dy)

もちろん、特定の値、フィールド、またはパラメーターに実際に想定する型を F# コンパイラに指示する必要がある場合や指示したい場合は、型の注釈を使用できます。その場合は、この注釈情報が型の推論に使用されます。たとえば、次のように、ほんの少し型の注釈を追加して、int ではなく float を使用するように Point2D の定義を変更できます。

type Point2D(x : float,y : float) = 
    member this.X = x
    member this.Y = y
    member this.Magnitude = 
        x*x + y*y
    member this.Translate(dx, dy) = 
        new Point2D(x + dx, y + dy)

型の推論の重要な結果の 1 つは、特定の型に関連付けられていない関数が自動的に汎用化されて、ジェネリック関数になることです。このため、コードは、すべてのジェネリック型を明示的に指定しなくても、可能な限りジェネリックになります。そのため、F# ではジェネリックが重要な役割を果たします。F# による関数型プログラミングの複合的なスタイルでは、機能が再利用可能な小さな部品として作成されることが多くなります。これは、可能な限りジェネリックになるということから大きなメリットを得ています。複合型の注釈を付けずにジェネリック関数を作成できることは、F# の重要な特徴です。

たとえば、次の map 関数は値のリスト全体を移動し、各要素に f という引数関数を適用して新しいリストを生成します。

let rec map f values = 
    match values with
    | [] -> []
    | x :: rest -> (f x) :: (map f rest)

型の注釈は必要ありませんが、map に対して推論される型は "map : (‘a -> ‘b) -> list<’a>  -> list<’b>" であることに注意してください。F# は、パターン マッチングの使用法、および関数としての f パラメーターの使用法から、2 つのパラメーターの型は特定の形をとっても完全に固定されないことを推論できます。このため、F# は、実装で必要な型を保持しながら、可能な限り関数をジェネリックにします。F# のジェネリック パラメーターは、他の名前と構文的に区別するために、先頭に ‘ という文字を使用して示すことに注意してください。

F# の設計者である Don Syme 氏は、以前は .NET Framework 2.0 のジェネリックの実装に関する主任研究者兼開発者でした。F# のような言語の概念は、ランタイムにジェネリックが含まれることに大きく依存しています。F# を設計する際の Syme 氏の関心事項は、一部、この CLR 機能を有効に活用したいという想いから生じました。F# では .NET のジェネリックに大きく依存しています。たとえば、F# コンパイラ自体の実装には 9,000 を超えるジェネリック型のパラメーターが含まれています。

ただし、結局、型の推論はコンパイル時の機能にすぎず、F# コードの各部分は、F# アセンブリの CLR メタデータでエンコードされた推論型を受け取ります。

末尾呼び出し

不変性と関数型プログラミングにより、F# の計算ツールに再帰を使用することが多くなる傾向があります。たとえば、次のように、F# リスト全体を移動し、シンプルな再帰 F# コードを使用してリスト内の値を 2 乗した合計を収集できます。

let rec sumOfSquares nums =
    match nums with
    | [] -> 0
    | n :: rest -> (n*n) + sumOfSquares rest

多くの場合、再帰は便利ですが、反復処理ごとに新しいスタック フレームが追加されるため、呼び出しスタックの領域が大量に使用される可能性があります。入力が多くなりすぎると、スタック オーバーフロー例外が発生することもあります。このスタック領域の拡大を回避するため、末尾再帰的なコードを記述できます。つまり、再帰呼び出しは必ず関数が返される直前の最後に実行される処理になります。

let rec sumOfSquaresAcc nums acc = 
    match nums with 
    | [] -> acc
    | n :: rest -> sumOfSquaresAcc rest (acc + n*n)

F# コンパイラは、スタックが拡大しないようにすることを目的とした 2 つの技法を駆使して、末尾再帰関数を実装します。定義されている同じ関数の直接末尾呼び出し (sumOfSquaresAcc の呼び出しなど) の場合、F# コンパイラによって自動的に再帰呼び出しが while ループに変換されるため、実際の呼び出しを行わずに、同じ関数の命令型実装によく似たコードが生成されます。

ただし、末尾再帰は必ずしもこのように単純ではなく、複数の相互再帰関数になる可能性があります。このような場合、F# コンパイラは末尾呼び出しを CLR ネイティブ サポートに依存します。

CLR には、特に末尾再帰に役立つ tail. IL プレフィックスという IL 命令があります。tail. 命令は、関連付けられた呼び出しを行う前に呼び出し元のメソッドの状態を破棄できることを CLR に通知します。つまり、この呼び出しを行っているときはスタックが拡大しません。また、少なくとも原則としては、JIT がジャンプ命令だけを使用して呼び出しを効率的に行うことができることも意味します。このことは F# にとって有効で、末尾再帰がほぼすべての場合に安全であることが保証されます。

IL_0009:  tail.
IL_000b:  call    bool Program/SixThirtyEight::odd(int32)
IL_0010:  ret

CLR 4.0 では、末尾呼び出しの処理に対していくつかの重要な機能強化が施されています。x64 JIT は以前、非常に効率的に末尾呼び出しを実装していましたが、tail. 命令が使用されるすべての状況に適用できないことがある技法を使用しています。つまり、x86 プラットフォームで正常に実行された一部の F# コードが、x64 プラットフォームではスタック オーバーフローで失敗します。CLR 4.0 では、x64 JIT の末尾呼び出しの効率的な実装がより多くの状況に対応するようになり、末尾呼び出しが x86 JIT に存在するときは必ずその呼び出しが行われるようにするために必要な、オーバーヘッドの大きなメカニズムを実装します。

末尾呼び出しに関する CLR 4.0 機能強化の詳細な説明については、CLR コード生成に関するブログ (blogs.msdn.com/clrcodegeneration/archive/2009/05/11/tail-call-improvements-in-net-framework-4.aspx、英語) を参照してください。

F# Interactive

F# Interactive はコマンド ライン ツールで、F# コードを対話的に実行する Visual Studio ツール ウィンドウです (図 1 参照)。このツールによって、データを試したり、API を調査したり、F# を使用してアプリケーション ロジックをテストしたりすることが容易になります。F# Interactive は CLR Reflection.Emit API によって可能になりました。この API を使用すると、プログラムで実行時に新しい型とメンバーを生成し、この新しいコードを動的に呼び出すことができます。F# Interactive では、F# コンパイラを使用してユーザーがプロンプトに入力するコードをコンパイルし、ディスクにアセンブリを書き込むのではなく、Reflection.Emit を使用して型、関数、およびメンバーを生成します。

Executing Code in F# Interactive
図 1 F# Interactive でのコードの実行

この手法の重要な結果は、実行されるユーザー コードが、インタープリターで実行されるバージョンの F# になるのではなく、完全にコンパイルされて JIT 化され、その両方の手順に役立つ最適化がすべて行われることです。これが F# Interactive を優れたツールにしており、新しい問題解決アプローチを試して大きなデータセットを対話的に調査するためのパフォーマンスの高い環境になります。

F# の組では、新しいカスタム型を定義したり、out パラメーターなどの複雑なパラメーター方式を使用して複数の値を返したりする必要がない、データをパッケージにしてユニットとして渡すシンプルな方法が提供されます。

let printPersonData (name, age) = 
    printfn "%s is %d years old" name age

let bob = ("Bob", 34)

printPersonData bob

    
let divMod n m = 
    n / m, n % m

let d,m = divMod 10 3

組とは単純な型ですが、F# の重要なプロパティをいくつか備えています。最も重要なことは、組が不変であることです。組の要素は、いったん作成したら変更できません。このため、組は単なる要素の組み合わせとして安全に処理されます。また、組のもう一つの重要な機能である構造的等値も可能になります。組や他の F# 型 (リスト、オプション、ユーザー定義のレコードとユニオンなど) は、要素をそれぞれ比較することによって、等しいかどうかが判断されます。

.NET Framework 4 では、組は基本のクラス ライブラリで定義される中核となるデータ型になりました。.NET Framework 4 を対象とするとき、F# は System.Tuple 型を使用してこれらの値を表します。mscorlib でこの中核となる型をサポートすることは、F# ユーザーが C# API と組を簡単に共有できることを意味します。

組は概念的にはシンプルな型ですが、System.Tuple 型の構築に関連する興味深い設計上の決定事項が多数あります。Matt Ellis は最新の「CLR 徹底解剖」コラム (msdn.microsoft.com/magazine/dd942829、英語) で組の設計プロセスについて詳しく説明しています。

最適化

F# が直接 CLR 命令に変換されることはあまりないため、F# コンパイラは単純に CLR JIT コンパイラに依存するのではなく、最適化を行う余地があります。F# コンパイラはこれを利用して、C# コンパイラや Visual Basic コンパイラよりも多くの最適化をリリース モードに施します。

1 つの簡単な例として、中間的な組が取り除かれます。データを処理中にデータを構造化するために組がよく使用されます。通常、組は、1 つの関数の本体内で作成されてから解体されます。これを行うときに、組オブジェクトの不必要な割り当てが生じます。F# コンパイラは、組を作成して解体しても重要な副作用が生じないことを認識しているので、中間的な組を割り当てないようにします。

次の例で組オブジェクトが使用されるのは、パターン マッチング式で解体される場合のみなので、組オブジェクトを割り当てる必要はありません。

let getValueIfBothAreSame x y = 
    match (x,y) with
    | (Some a, Some b) when a = b -> Some a
    |_ -> None

単位

メートルや秒といった単位は科学、エンジニアリング、シミュレーションなどでよく使用され、基本的にはさまざまな種類の数量を扱うための型システムです。F# では、単位が言語の型システムに直接持ち込まれているため、数量に注釈として単位を付けることができます。このような単位は計算の最後まで維持され、単位が一致しない場合はエラーが報告されます。次の例では、kg (キログラム) を s (秒) で除算するのはエラーではありませんが、kg と s を加算しようとするとエラーになります。

[<Measure>] type kg
/// Seconds
[<Measure>] type s
    
let x = 3.0<kg>
//val x : float<kg>

let y = 2.5<s>
// val y : float<s>

let z = x / y
//val z : float<kg/s>

let w = x + y
// Error: "The unit of measure 's' 
// does not match the unit of measure 'kg'"

F# の型の推論のおかげで、単位はそれほど大きな追加ではありません。型の推論を使用する場合、ユーザーが指定する単位の注釈はリテラルでのみ、および外部ソースからデータを受け取るときにのみに指定する必要があります。そのため、型の推論は、ユーザーが指定する単位の注釈をプログラムを通じて伝達し、使用される単位に従ってすべての計算が正しく実行されていることをチェックします。

単位は F# 型システムの一部ですが、コンパイル時に消去されます。つまり、処理後の .NET アセンブリには単位に関する情報が含まれず、CLR によって、単位が設定された値のみが元の型として処理されます。そのため、パフォーマンスのオーバーヘッドは発生しません。これは、実行時に完全に使用できる .NET ジェネリックとは対照的です。

今後、CLR によって中核となる CLR 型システムに単位が統合された場合、F# は単位情報を公開できるため、他の .NET プログラミング言語でその情報を確認できます。

F# との対話

既におわかりのように、F# によって、.NET Framework 向けに、表現力に富んだ関数型のオブジェクト指向の探究的プログラミング言語が提供されます。F# は Visual Studio 2010 に統合され、F# 言語を直接使用して動作を試すための F# Interactive ツールが含まれています。

F# 言語とツールは CLR のすべてを利用し、CLR のメタデータと IL にマップされる、より高いレベルの概念をいくつか導入します。結局のところ F# はありふれた .NET 言語であり、共通型システムとランタイムのおかげで新しい .NET プロジェクトまたは既存の .NET プロジェクトのコンポーネントとして容易に組み込むことができます。

Luke Hoban は、マイクロソフトで F# チームのプログラム マネージャーを務めています。F# チームに異動する前は、C# コンパイラのプログラム マネージャーを務め、C# 3.0 と LINQ に従事していました。