F# のコーディング規則
次の規則は、大きな F# コードベースの使用経験から作成されたものです。 「優れた F# コードの 5 つの原則」は、各推奨事項の基礎となります。 これらは「F# コンポーネント デザインのガイドライン」に関連していますが、ライブラリなどのコンポーネントだけでなく、すべての F# コードに適用されます。
コードの整理
F# には、コードを整理するための主な方法として、モジュールと名前空間の 2 つがあります。 これらは似ていますが、次の点が異なります。
- 名前空間は、.NET 名前空間としてコンパイルされます。 モジュールは、静的クラスとしてコンパイルされます。
- 名前空間は常に最上位レベルです。 モジュールは、最上位レベルにすることも、他のモジュール内で入れ子にすることもできます。
- 名前空間は、複数のファイルにまたがることができます。 モジュールではできません。
- モジュールは、
[<RequireQualifiedAccess>]
および[<AutoOpen>]
で修飾できます。
次のガイドラインは、これらを使用してコードを整理する際に役立ちます。
最上位レベルでは名前空間を優先する
パブリックに使用できるコードでは、最上位レベルのモジュールより名前空間が優先されます。 これらは .NET 名前空間としてコンパイルされるため、C# では using static
を指定することなく使用できます。
// Recommended.
namespace MyCode
type MyClass() =
...
最上位レベルのモジュールを使用すると、F# からのみ呼び出す場合は違いがないかもしれませんが、C# のコンシューマーにとっては、特定の using static
C# コンストラクトを認識していない場合に MyClass
を MyCode
モジュールで修飾する必要があるため、呼び出し元が予期していない可能性があります。
// Will be seen as a static class outside F#
module MyCode
type MyClass() =
...
[<AutoOpen>]
を慎重に適用する
[<AutoOpen>]
構成体によって、呼び出し元が使用できるもののスコープが汚染される場合があります。また、その取得元は "マジック" になります。 これは良いことではありません。 このルールの例外として、F# コア ライブラリ自体があります (ただし、これにも少し議論の余地があります)。
ただし、パブリック API のヘルパー機能があり、そのパブリック API とは別に整理する必要がある場合に便利です。
module MyAPI =
[<AutoOpen>]
module private Helpers =
let helper1 x y z =
...
let myFunction1 x =
let y = ...
let z = ...
helper1 x y z
これにより、呼び出しのたびにヘルパーを完全に修飾することなく、関数のパブリック API から実装の詳細を明確に分離できます。
さらに、拡張メソッドと式ビルダーの名前空間レベルでの公開が、[<AutoOpen>]
を使用して簡潔に表現できます。
名前が競合する可能性がある場合や、読みやすさを向上させる場合には必ず [<RequireQualifiedAccess>]
を使用する
モジュールに [<RequireQualifiedAccess>]
属性を追加すると、モジュールを開くことができないこと、およびモジュールの要素への参照には明示的な修飾アクセスが必要であることが示されます。 たとえば、Microsoft.FSharp.Collections.List
モジュールにはこの属性があります。
これは、モジュールの関数と値の名前が、他のモジュールの名前と競合する可能性がある場合に便利です。 修飾されたアクセスを要求すると、ライブラリの長期的な保守性と発展性を大幅に向上させることができます。
[<RequireQualifiedAccess>]
module StringTokenization =
let parse s = ...
...
let s = getAString()
let parsed = StringTokenization.parse s // Must qualify to use 'parse'
open
ステートメントを位相的に並べ替える
F# では、open
ステートメント (および open type
、以降では単に open
と呼びます) を含め、宣言の順序が重要になります。 これは、using
と using static
の効果がファイル内のステートメントの順序に依存しない C# とは異なります。
F# では、スコープ内で開かれた要素は、既に存在する他の要素をシャドウできます。 これは、open
ステートメントを並べ替えるとコードの意味が変わる可能性があるということです。 その結果、すべての open
ステートメントの任意の並べ替え (たとえば、英数字順) を使用することは推奨されません。さもないと、予想とは異なる動作が生成されます。
代わりに、位相的に並べ替えることをお勧めします。つまり、open
ステートメントを、システムの "レイヤー" が定義されている順序で並べ替えます。 異なるトポロジ レイヤー内で英数字順の並べ替えを行うこともできます。
例として、F# コンパイラ サービスのパブリック API ファイルを位相的に並べ替えたものを次に示します。
namespace Microsoft.FSharp.Compiler.SourceCodeServices
open System
open System.Collections.Generic
open System.Collections.Concurrent
open System.Diagnostics
open System.IO
open System.Reflection
open System.Text
open FSharp.Compiler
open FSharp.Compiler.AbstractIL
open FSharp.Compiler.AbstractIL.Diagnostics
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.AbstractIL.ILBinaryReader
open FSharp.Compiler.AbstractIL.Internal
open FSharp.Compiler.AbstractIL.Internal.Library
open FSharp.Compiler.AccessibilityLogic
open FSharp.Compiler.Ast
open FSharp.Compiler.CompileOps
open FSharp.Compiler.CompileOptions
open FSharp.Compiler.Driver
open Internal.Utilities
open Internal.Utilities.Collections
改行でトポロジ レイヤーが分離され、その後で各レイヤーが英数字順に並べ替えられます。 これにより、誤って値をシャドウすることなくコードを整理できます。
クラスを使用して副作用のある値を封じ込める
データベースまたはその他のリモート リソースへのコンテキストのインスタンス化など、値の初期化によって副作用が生じる場合が多くあります。 次のように、これらをモジュール内で初期化して後続の関数で使用することは魅力的です。
// Not recommended, side-effect at static initialization
module MyApi =
let dep1 = File.ReadAllText "/Users/<name>/config-options.txt"
let dep2 = Environment.GetEnvironmentVariable "DEP_2"
let private r = Random()
let dep3() = r.Next() // Problematic if multiple threads use this
let function1 arg = doStuffWith dep1 dep2 dep3 arg
let function2 arg = doStuffWith dep1 dep2 dep3 arg
多くの場合、これは次のような理由で問題になります。
最初に、アプリケーション構成は dep1
と dep2
を使用してコードベースにプッシュされます。 より大きなコードベースでは、これを維持するのは困難です。
2 番目に、コンポーネント自体で複数のスレッドを使用する場合、静的に初期化されたデータには、スレッド セーフではない値を含めないでください。 これは、dep3
が明確に違反しています。
最後に、モジュールの初期化は、コンパイル単位全体の静的コンストラクターにコンパイルされます。 このモジュールの let でバインドされた値の初期化でエラーが発生した場合、そのエラーは TypeInitializationException
としてマニフェストを持ち、アプリケーションの有効期間全体にわたってキャッシュされます。 これによって診断が困難になる場合があります。 通常は判断できる内部例外がありますが、それが存在しない場合、根本原因については何も通知されません。
代わりに、単純なクラスを使用して依存関係を保持するだけにします。
type MyParametricApi(dep1, dep2, dep3) =
member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2
これにより、次のことが可能になります。
- API 自体の外部に依存状態をプッシュします。
- API の外部で構成を実行できるようになります。
- 依存する値の初期化でエラーが発生しても、
TypeInitializationException
としてマニフェストを持つ可能性が低くなります。 - API のテストが簡単になります。
エラー管理
大規模なシステムにおけるエラー管理は複雑で微妙な作業であり、システムがフォールト トレラントで適切に動作しているかどうかを確認するための万全の解決策はありません。 次のガイドラインでは、このような困難な作業を行う際のガイダンスを提供します。
ドメインに固有の型のエラー ケースと無効な状態を表現する
F# では、判別共用体を使用して、型システム内の問題のあるプログラムの状態を表すことができます。 次に例を示します。
type MoneyWithdrawalResult =
| Success of amount:decimal
| InsufficientFunds of balance:decimal
| CardExpired of DateTime
| UndisclosedFailure
この場合、銀行口座からの出金に失敗する可能性がある既知の方法が 3 つあります。 各エラー ケースは型で表されるため、プログラム全体で安全に処理できます。
let handleWithdrawal amount =
let w = withdrawMoney amount
match w with
| Success am -> printfn $"Successfully withdrew %f{am}"
| InsufficientFunds balance -> printfn $"Failed: balance is %f{balance}"
| CardExpired expiredDate -> printfn $"Failed: card expired on {expiredDate}"
| UndisclosedFailure -> printfn "Failed: unknown"
一般に、ドメイン内で何らかのエラーが発生する可能性のあるさまざまな方法をモデル化できる場合、エラー処理コードは、通常のプログラム フローに加えて処理する必要のあるものではなくなります。 これは単に通常のプログラム フローの一部であり、例外的とは見なされません。 これには、以下の 2 つの主な利点があります。
- 時間の経過と共にドメインが変化しても、保守が容易になります。
- エラー ケースの単体テストが簡単になります。
エラーを型で表すことができない場合に例外を使用する
問題のあるドメインで表現することができないエラーもあります。 この種のエラーは本質的に "例外的" であるため、F# では例外を発生させてキャッチすることができます。
まず、「例外のデザインのガイドライン」を読むことをお勧めします。 これらは F# にも当てはまります。
例外を発生させるために F# で使用できる主な構成要素は、次の優先順位で考慮する必要があります。
機能 | 構文 | 目的 |
---|---|---|
nullArg |
nullArg "argumentName" |
指定した引数名を使用して System.ArgumentNullException を発生させます。 |
invalidArg |
invalidArg "argumentName" "message" |
指定した引数名とメッセージを使用して System.ArgumentException を発生させます。 |
invalidOp |
invalidOp "message" |
指定したメッセージを使用して System.InvalidOperationException を発生させます。 |
raise |
raise (ExceptionType("message")) |
例外をスローするための汎用的なメカニズム。 |
failwith |
failwith "message" |
指定したメッセージを使用して System.Exception を発生させます。 |
failwithf |
failwithf "format string" argForFormatString |
書式指定文字列とその入力によって決定されたメッセージを使用して System.Exception を発生させます。 |
必要に応じて ArgumentNullException
、ArgumentException
、InvalidOperationException
をスローするためのメカニズムとして、nullArg
、invalidArg
、invalidOp
を使用します。
failwith
関数と failwithf
関数は、通常、特定の例外ではなく基本の Exception
型を生成するため、回避する必要があります。 「例外のデザインのガイドライン」に従って、可能な限りより具体的な例外を生成する必要があります。
例外処理構文を使用する
F# では、次の try...with
構文を使用して例外パターンをサポートしています。
try
tryGetFileContents()
with
| :? System.IO.FileNotFoundException as e -> // Do something with it here
| :? System.Security.SecurityException as e -> // Do something with it here
パターン マッチングを使用して例外が発生したときに実行する機能を調整することは、コードをクリーンな状態に保つ必要がある場合に少し厄介になることがあります。 このような処理を行う方法の 1 つに、エラーの発生したケースを例外自体で囲む機能をグループ化する手段として、アクティブ パターンを使用する方法があります。 たとえば、例外がスローされたときに、例外メタデータで重要な情報を囲む API を使用しているとします。 アクティブ パターン内でキャプチャされた例外の本体内にある有用な値をラップ解除し、その値を返すことは状況によっては役に立ちます。
モナド エラー処理を使用して例外を置き換えない
純粋関数型パラダイムでは、しばしば例外はタブーと見なされます。 実際、例外は純粋性に違反するため、必ずしも機能的に純粋ではないと考える方が安全です。 しかしながら、これは、コードを実行する必要がある場所の現実と、ランタイム エラーが発生する可能性があることを無視しています。 一般には、望ましくない問題を最小限に抑えるために、ほとんどのものが純粋でも完全でもないことを前提としてコードを記述します (C# の空の catch
やスタック トレースの誤った管理、情報の破棄と同様)。
.NET ランタイムでの関連性と妥当性、および言語間のエコシステム全体に関して、次のように例外の主要な長所や側面を考慮することが重要です。
- 詳細な診断情報が含まれており、問題をデバッグするときに便利です。
- ランタイムやその他の .NET 言語によってよく理解されています。
- セマンティクスのサブセットをアドホック ベースで実装することによって故意に例外を "回避" するコードと比較すると、重要な定型句を減らすことができます。
この 3 番目のポイントは重要です。 複雑な操作の場合、例外を使用しないと、次のような構造体が処理される可能性があります。
Result<Result<MyType, string>, string list>
これは、"文字列で型指定された" エラーでのパターン マッチングのような脆弱なコードにつながる可能性があります。
let result = doStuff()
match result with
| Ok r -> ...
| Error e ->
if e.Contains "Error string 1" then ...
elif e.Contains "Error string 2" then ...
else ... // Who knows?
さらに、次のような "より良い" 型を返す "単純な" 関数を希望する場合には、例外を飲み込みたくなります。
// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
try System.IO.File.ReadAllText path |> Some
with _ -> None
残念ながら、tryReadAllText
は、ファイル システムで発生する可能性のあるあらゆるものに基づいて多数の例外をスローすることがあります。このコードでは、環境で実際に発生した問題に関する情報が破棄されます。 このコードを結果の型に置き換えると、次のような "文字列で型指定された" エラー メッセージの解析に戻ります。
// Problematic, callers only have a string to figure the cause of error.
let tryReadAllText (path : string) =
try System.IO.File.ReadAllText path |> Ok
with e -> Error e.Message
let r = tryReadAllText "path-to-file"
match r with
| Ok text -> ...
| Error e ->
if e.Contains "uh oh, here we go again..." then ...
else ...
また、例外オブジェクト自体を Error
コンストラクターに配置しても、関数ではなく、呼び出しサイトで例外の型を適切に処理するように強制されるだけです。 これによって、事実上、チェックされた例外が作成されます。これは、API の呼び出し元として対応するのは楽しいものでないことが周知されています。
上記の例の代わりに、"特定の" 例外をキャッチし、その例外のコンテキストで意味のある値を返す方法があります。 tryReadAllText
関数を次のように変更すると、None
の意味が大きくなります。
let tryReadAllTextIfPresent (path : string) =
try System.IO.File.ReadAllText path |> Some
with :? FileNotFoundException -> None
この関数は、キャッチオールとして機能するのではなく、ファイルが見つからなかった場合を適切に処理し、その意味を戻り値に割り当てるようになりました。 この戻り値はそのエラー ケースにマップできますが、コンテキスト情報を破棄したり、コード内のその時点で関連しない可能性のあるケースを呼び出し元に処理させたりすることはありません。
Result<'Success, 'Error>
などの型は、入れ子になっていない基本操作に適しています。また、F# のオプションの型は、"何か" を返すか "何も" 返さないかのいずれかである場合を表すのに最適です。 ただし、これらは例外の代わりにはならないので、例外を置換するためには使用しないでください。 代わりに、対象の方法で例外およびエラー管理ポリシーの特定の側面に対処するために、慎重に適用する必要があります。
部分的なアプリケーションとポイントフリー プログラミング
F# では部分的なアプリケーションをサポートしているため、ポイントフリー スタイルでプログラミングするさまざまな方法がサポートされています。 これは、モジュール内でのコードの再利用や何らかの実装に役立つ場合がありますが、パブリックに公開するものではありません。 一般に、ポイントフリー プログラミング自体は美徳ではありません。また、このスタイルに没頭していない人にとっては、重大な認知の壁になる可能性があります。
パブリック API で部分的なアプリケーションとカリー化を使用しない
例外がほとんどない場合、パブリック API で部分的なアプリケーションを使用すると、コンシューマーにとって混乱を招く可能性があります。 F# コードでは通常、let
でバインドされた値は、関数値ではなく値です。 値と関数値を混在させても、著しく認識しにくくなる代わりに、わずかな行数のコードを省略できるだけの場合があります。これは特に、>>
などの演算子と組み合わせて関数を作成する場合に発生します。
ポイントフリー プログラミングへのツールの影響について検討する
カリー化関数では、引数にラベルが付けられません。 これによって、ツールの影響がもたらされます。 次の 2 つの関数について考えてみます。
let func name age =
printfn $"My name is {name} and I am %d{age} years old!"
let funcWithApplication =
printfn "My name is %s and I am %d years old!"
どちらも有効な関数ですが、funcWithApplication
はカリー化関数です。 エディターでその型にマウス ポインターを合わせると、次のように表示されます。
val func : name:string -> age:int -> unit
val funcWithApplication : (string -> int -> unit)
呼び出しサイトでは、Visual Studio などのツールのツールヒントによって型シグネチャが提供されますが、名前が定義されていないため、名前は表示されません。 名前は呼び出し元が API の背後にある意味をより深く理解するのに役立つため、優れた API 設計に不可欠です。 パブリック API でポイントフリー コードを使用すると、呼び出し元が理解しづらくなる可能性があります。
パブリックに使用できる funcWithApplication
のようなポイントフリーのコードがあった場合は、完全な η-展開を実行することをお勧めします。これにより、ツールが引数の意味のある名前を取得できるようになります。
さらに、ポイントフリーのコードのデバッグは、不可能ではないにしても、困難な場合があります。 デバッグ ツールは、名前にバインドされた値 (たとえば、let
でのバインド) に依存しているので、実行の途中で中間値を検査できます。 コードに検査する値がない場合、デバッグするものはありません。 将来的には、デバッグ ツールは、以前に実行されたパスに基づいてこれらの値を合成するように発展する可能性もありますが、"潜在的な" デバッグ機能に賭けるのは得策ではありません。
内部の定型句を減らすための手法として部分的なアプリケーションを検討する
前の点とは対照的に、部分的なアプリケーションは、アプリケーション内の定型句または API の深い内部構造を減らすための優れたツールです。 これは、しばしば定型的な処理が面倒になる、より複雑な API の実装の単体テストを行うのに便利です。 たとえば、次のコードは、このようなフレームワークに外部依存関係を持たず、関連する特注の API を習得することなく、ほとんどのモック フレームワークで提供される機能を実現する方法を示しています。
たとえば、次のソリューション トポグラフィを考えてみます。
MySolution.sln
|_/ImplementationLogic.fsproj
|_/ImplementationLogic.Tests.fsproj
|_/API.fsproj
ImplementationLogic.fsproj
では、次のようなコードが公開される場合があります。
module Transactions =
let doTransaction txnContext txnType balance =
...
type Transactor(ctx, currentBalance) =
member _.ExecuteTransaction(txnType) =
Transactions.doTransaction ctx txnType currentBalance
...
ImplementationLogic.Tests.fsproj
での Transactions.doTransaction
の単体テストは簡単です。
namespace TransactionsTestingUtil
open Transactions
module TransactionsTestable =
let getTestableTransactionRoutine mockContext = Transactions.doTransaction mockContext
モック コンテキスト オブジェクトで doTransaction
を部分的に適用すると、毎回モック コンテキストを構築しなくても、すべての単体テストで関数を呼び出すことができます。
module TransactionTests
open Xunit
open TransactionTypes
open TransactionsTestingUtil
open TransactionsTestingUtil.TransactionsTestable
let testableContext =
{ new ITransactionContext with
member _.TheFirstMember() = ...
member _.TheSecondMember() = ... }
let transactionRoutine = getTestableTransactionRoutine testableContext
[<Fact>]
let ``Test withdrawal transaction with 0.0 for balance``() =
let expected = ...
let actual = transactionRoutine TransactionType.Withdraw 0.0
Assert.Equal(expected, actual)
この手法をコードベース全体に対して汎用的に適用しないでください。ただし、複雑な内部構造およびそれらの内部の単体テストのために定型句を減らすにはよい方法です。
アクセス制御
F# には、.NET ランタイムで使用できるものから継承されたアクセス制御のためのオプションが複数あります。 これらは、型だけでなく、関数にも使用できます。
広く使用されている、ライブラリのコンテキストでの適切なプラクティス:
- パブリックに使用できるようにする必要がなければ、
public
でない型とメンバーを優先します。 これにより、コンシューマーが結合するものを最小限に抑えることもできます。 - すべてのヘルパー機能を
private
にするよう努力してください。 - ヘルパー関数が多数になる場合は、ヘルパー関数のプライベート モジュールで
[<AutoOpen>]
を使用することを検討してください。
型推論とジェネリック
型推論によって、大量の定型句の入力を省略することができます。 また、F# コンパイラの自動ジェネリック化は、追加の作業をほとんど必要とせずに、より汎用的なコードを作成するのに役立ちます。 ただし、これらの機能は、全般的に良好なものではありません。
パブリック API では明示的な型を使用して引数名にラベルを付けることを検討し、このために型推論に頼らないでください。
その理由は、API の構造はコンパイラではなく自分で制御する必要があるためです。 コンパイラは型推論を適切に実行できますが、依存している内部構造で型が変更された場合は、API の構造が変更される可能性があります。 これは希望どおりであることもありますが、ほとんどの場合に API の破壊的変更が行われ、ダウンストリームのコンシューマーが対処する必要があります。 代わりに、パブリック API の構造を明示的に制御する場合は、これらの破壊的変更を制御できます。 DDD の用語では、これは破損対策レイヤーと考えることができます。
汎用引数にわかりやすい名前を付けることを検討してください。
特定のドメインに固有ではない真に汎用的なコードを記述する場合を除き、わかりやすい名前を付けることで、他のプログラマが作業中のドメインを理解するのに役立ちます。 たとえば、ドキュメント データベースを操作するコンテキストで
'Document
という名前の型パラメーターを使用すると、一般的なドキュメントの種類が、作業している関数またはメンバーによって受け入れられることがわかりやすくなります。ジェネリック型パラメーターには、パスカル ケースを使用して名前を付けることを検討してください。
これは .NET で作業を行うための一般的な方法であるため、スネーク ケースまたはキャメル ケースではなく、パスカル ケースを使用することをお勧めします。
最後に、F# または大規模なコードベースを初めて使用するユーザーにとっては、自動ジェネリック化が常に有益なものになるとは限りません。 ジェネリック コンポーネントを使用すると、認識のオーバーヘッドが発生します。 さらに、自動的にジェネリック化された関数がさまざまな入力型で使用されていない場合 (そのように使用することを意図している場合は言うまでもなく)、そのときにジェネリックであることの実際の利点はありません。 記述しているコードをジェネリックにすることに実際に利点があるかどうかを常に考慮してください。
パフォーマンス
割り当て率の高い小さい型に構造体を検討する
構造体 (値型とも呼ばれます) を使用すると、多くの場合、オブジェクトの割り当てが回避されるため、一部のコードのパフォーマンスが向上します。 ただし、構造体が常に "高速化" ボタンになるとは限りません。構造体内のデータのサイズが 16 バイトを超える場合、データをコピーすると、参照型を使用するよりも多くの CPU 時間が消費される可能性があります。
構造体を使用するかどうかを判断するには、次の条件を考慮してください。
- データのサイズが 16 バイト以下の場合。
- これらの型のインスタンスの多くが、実行中のプログラムのメモリに常駐している可能性が高い場合。
最初の条件に当てはまる場合、通常は構造体を使用する必要があります。 両方とも当てはまる場合は、ほぼ必ず構造体を使用する必要があります。 前の条件に当てはまっても、構造体の使用は参照型を使用するのと変わらないか、さらに悪くなる場合もあります。ただし、このような場合はまれにしかありません。 しかしながら、このような変更を行う場合は常に測定することが重要です。また、仮定や直感に基づいて操作しないでください。
割り当て率の高い小さい値型をグループ化する場合は、構造体タプルを検討する
次の 2 つの関数について考えてみます。
let rec runWithTuple t offset times =
let offsetValues x y z offset =
(x + offset, y + offset, z + offset)
if times <= 0 then
t
else
let (x, y, z) = t
let r = offsetValues x y z offset
runWithTuple r offset (times - 1)
let rec runWithStructTuple t offset times =
let offsetValues x y z offset =
struct(x + offset, y + offset, z + offset)
if times <= 0 then
t
else
let struct(x, y, z) = t
let r = offsetValues x y z offset
runWithStructTuple r offset (times - 1)
BenchmarkDotNet のような統計ベンチマーク ツールを使用してこれらの関数のベンチマークを行うと、構造体タプルを使用する runWithStructTuple
関数は 40% 高速に実行され、メモリが割り当てられないことがわかります。
ただし、これらの結果は、必ずしも自分のコードに当てはまるとは限りません。 関数を inline
としてマークすると、参照タプルを使用するコードに追加の最適化が行われることがあります。また、割り当てられるコードが最適化されるだけの可能性もあります。 パフォーマンスに懸念がある場合は常に結果を測定し、決して仮定や直感に基づいて操作しないでください。
型が小さく割り当て率が高い場合は、構造体レコードを検討する
前述の経験則は、F# のレコード型についても該当します。 これらを処理する次のデータ型と関数について考えてみます。
type Point = { X: float; Y: float; Z: float }
[<Struct>]
type SPoint = { X: float; Y: float; Z: float }
let rec processPoint (p: Point) offset times =
let inline offsetValues (p: Point) offset =
{ p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }
if times <= 0 then
p
else
let r = offsetValues p offset
processPoint r offset (times - 1)
let rec processStructPoint (p: SPoint) offset times =
let inline offsetValues (p: SPoint) offset =
{ p with X = p.X + offset; Y = p.Y + offset; Z = p.Z + offset }
if times <= 0 then
p
else
let r = offsetValues p offset
processStructPoint r offset (times - 1)
これは前のタプル コードに似ていますが、今回の例ではレコードとインライン内部関数を使用します。
BenchmarkDotNet のような統計ベンチマーク ツールを使用してこれらの関数のベンチマークを行うと、processStructPoint
は約 60% 高速に実行され、マネージド ヒープには何も割り当てられないことがわかります。
データ型が小さく割り当て率が高い場合は、構造体の判別共用体を検討する
構造体タプルとレコードを使用したパフォーマンスに関する前の観察は、F# の判別共用体についても該当します。 次のコードがあるとします。
type Name = Name of string
[<Struct>]
type SName = SName of string
let reverseName (Name s) =
s.ToCharArray()
|> Array.rev
|> System.String
|> Name
let structReverseName (SName s) =
s.ToCharArray()
|> Array.rev
|> System.String
|> SName
ドメイン モデリングには、このような単一ケースの判別共用体を定義するのが一般的です。 BenchmarkDotNet のような統計ベンチマーク ツールを使用してこれらの関数のベンチマークを行うと、structReverseName
は小さい文字列に対して reverseName
よりも約 25% 高速に実行されることがわかります。 大きな文字列の場合は、両者のパフォーマンスはほぼ同じです。 そのため、この場合は、常に構造体を使用することをお勧めします。 前述のように、常に測定を行い、仮定や直感に基づいて操作しないでください。
前の例では、構造体の判別共用体でパフォーマンスが向上したことが示されましたが、ドメインをモデル化する場合は、より大きな判別共用体を使用するのが一般的です。 このような大規模なデータ型は、それに対する操作に応じた構造体である場合には、より多くのコピーが関係する可能性があるため、パフォーマンスが同等にならない可能性があります。
不変性と変化
F# の値は、既定では変更できません。これにより、特定のクラスのバグ (特に同時実行性と並列処理を伴うもの) を回避できます。 ただし、場合によっては、実行時間やメモリの割り当ての効率を最適化 (または合理化) するために、状態のインプレース変化を使用して作業の範囲を実装することをお勧めします。 これは、F# では mutable
キーワードを使用してオプトインできます。
F# で mutable
を使用するのは、機能的な純粋性について違和感があるかもしれません。 それは理解できますが、あらゆる場所で機能的な純粋性を保つことは、パフォーマンス目標とは相容れない場合があります。 妥協点は、呼び出し元が関数を呼び出したときの動作を気にする必要がないように、変化をカプセル化することです。 これにより、パフォーマンス クリティカルなコードのために、変更ベースの実装に対して機能インターフェイスを記述できます。
また、F# let
バインド コンストラクトを使用すると、バインドを別のバインドに入れ子にすることができます。これを利用して、変数の mutable
スコープを近くに、または理論的に最小に保つことができます。
let data =
[
let mutable completed = false
while not completed do
logic ()
// ...
if someCondition then
completed <- true
]
どのコードも、data
let でバインドされた値の初期化のみに使用された変更可能な completed
にはアクセスできません。
変更可能なコードを変更できないインターフェイスでラップする
参照の透過性を目標として、パフォーマンス クリティカルな関数の変更可能な弱点を公開しないコードを記述することが重要です。 たとえば、次のコードでは、F# コア ライブラリの Array.contains
関数を実装しています。
[<CompiledName("Contains")>]
let inline contains value (array:'T[]) =
checkNonNull "array" array
let mutable state = false
let mutable i = 0
while not state && i < array.Length do
state <- value = array[i]
i <- i + 1
state
この関数を複数回呼び出しても、基になる配列が変更されることはありません。また、使用中に変更可能な状態を維持する必要もありません。 その中のほぼすべてのコード行で変化が使用されていても、参照に関して透過的になります。
変更可能なデータをクラスにカプセル化することを検討する
前の例では、変更可能なデータを使用して操作をカプセル化するために単一の関数を使用しました。 これは、より複雑なデータ セットには必ずしも十分ではありません。 次の一連の関数について考えてみます。
open System.Collections.Generic
let addToClosureTable (key, value) (t: Dictionary<_,_>) =
if t.ContainsKey(key) then
t[key] <- value
else
t.Add(key, value)
let closureTableCount (t: Dictionary<_,_>) = t.Count
let closureTableContains (key, value) (t: Dictionary<_, HashSet<_>>) =
match t.TryGetValue(key) with
| (true, v) -> v.Equals(value)
| (false, _) -> false
このコードはパフォーマンスに優れていますが、呼び出し元が保持する変更ベースのデータ構造を公開しています。 これは、次のように、基になる変更可能なメンバーを持たないクラスの内部にラップできます。
open System.Collections.Generic
/// The results of computing the LALR(1) closure of an LR(0) kernel
type Closure1Table() =
let t = Dictionary<Item0, HashSet<TerminalIndex>>()
member _.Add(key, value) =
if t.ContainsKey(key) then
t[key] <- value
else
t.Add(key, value)
member _.Count = t.Count
member _.Contains(key, value) =
match t.TryGetValue(key) with
| (true, v) -> v.Equals(value)
| (false, _) -> false
Closure1Table
では、基になる変更ベースのデータ構造をカプセル化し、呼び出し元が基になるデータ構造を維持することを強制しません。 クラスは、呼び出し元に詳細情報を公開せずに、変更ベースのデータとルーチンをカプセル化するための強力な方法です。
ref
よりも let mutable
を優先する
参照セルは、値自体ではなく、値への参照を表す方法です。 パフォーマンス クリティカルなコードには使用できますが、推奨されません。 次の例を確認してください。
let kernels =
let acc = ref Set.empty
processWorkList startKernels (fun kernel ->
if not ((!acc).Contains(kernel)) then
acc := (!acc).Add(kernel)
...)
!acc |> Seq.toList
参照セルを使用すると、後続のすべてのコードで、基になるデータを逆参照して再参照する必要があることが "汚染" になります。 代わりに、let mutable
を検討してください。
let kernels =
let mutable acc = Set.empty
processWorkList startKernels (fun kernel ->
if not (acc.Contains(kernel)) then
acc <- acc.Add(kernel)
...)
acc |> Seq.toList
ラムダ式の途中にある単一の変化のポイントを除いて、acc
に触れる他のすべてのコードは、通常の let
でバインドされた変更できない値の使用と変わらない方法で実行できます。 これにより、時間の経過と共に簡単に変更できるようになります。
null と既定値
通常、F# では null を避ける必要があります。 既定では、F# で宣言された型は null
リテラルの使用をサポートせず、すべての値とオブジェクトが初期化されます。 しかし、一般的な .NET API の中には、null を返すか受け入れるものがあり、配列や文字列などの一般的な .NET で宣言された型の中には、null が許可されるものがあります。 ただし、F# プログラミングでは null
値の出現は非常にまれであり、F# を使用する利点の 1 つは、ほとんどの場合、null 参照エラーが回避されることです。
AllowNullLiteral
属性の使用を避ける
既定では、F# で宣言された型は null
リテラルの使用をサポートしていません。 これを可能にするために、手動で F# 型に AllowNullLiteral
の注釈を付けることができます。 ただし、ほとんどの場合は、これを行わないことをお勧めします。
Unchecked.defaultof<_>
属性の使用を避ける
Unchecked.defaultof<_>
を使用して、F# 型の null
またはゼロで初期化された値を生成できます。 これは、一部のデータ構造のストレージを初期化する場合や、パフォーマンスの高いコーディング パターン、または相互運用性を実現する場合に役立ちます。 ただし、このコンストラクトの使用は避ける必要があります。
DefaultValue
属性の使用を避ける
既定では、F# のレコードとオブジェクトは構築時に適切に初期化する必要があります。 DefaultValue
属性を使用すると、オブジェクトの一部のフィールドに、null
またはゼロで初期化された値を設定できます。 このコンストラクトはめったに必要とされないので、その使用は避ける必要があります。
null の入力を確認する場合は、早い機会に例外を発生させる
新しい F# コードを記述する場合、C# やその他の .NET 言語からそのコードが使用されることが見込まれない限り、実際には null 入力を確認する必要はありません。
null 入力のチェックを追加する場合は、早い機会にチェックを実行し、例外を発生させます。 次に例を示します。
let inline checkNonNull argName arg =
if isNull arg then
nullArg argName
module Array =
let contains value (array:'T[]) =
checkNonNull "array" array
let mutable result = false
let mutable i = 0
while not state && i < array.Length do
result <- value = array[i]
i <- i + 1
result
従来の理由から、FSharp.Core の一部の文字列関数では引き続き null を空の文字列として扱い、null 引数では失敗しません。 ただし、これはガイダンスとして受け取らず、セマンティックの意味をすべて "null" に結び付けるコーディング パターンは採用しないでください。
オブジェクト プログラミング
F# では、オブジェクトとオブジェクト指向 (OO) の概念が完全にサポートされています。 OO の多くの概念は強力で便利ですが、すべての概念を使用するのが理想的であるとは限りません。 次の一覧は、OO の特徴の大まかなカテゴリに関するガイダンスを提供します。
さまざまな状況で次の機能を使用することを検討してください。
- ドット表記 (
x.Length
) - インスタンス メンバー
- 暗黙的なコンストラクター
- 静的メンバー
Item
プロパティを定義してのインデクサー表記 (arr[x]
)GetSlice
メンバーを定義してのスライス表記 (arr[x..y]
、arr[x..]
、arr[..y]
)- 名前付き引数と省略可能な引数
- インターフェイスとインターフェイスの実装
次の機能については真っ先に使用しないでください。問題を解決するのに便利な場合は、慎重に適用してください。
- メソッドのオーバーロード
- 変更可能なデータのカプセル化
- 型の演算子
- 自動プロパティ
IDisposable
とIEnumerable
の実装- 型拡張
- events
- 構造体
- 代理人
- 列挙型
一般に、次の機能は、使用する必要がある場合を除いて避けてください。
- 継承に基づく型の階層と実装の継承
- Null と
Unchecked.defaultof<_>
継承よりコンポジションを優先する
「継承よりコンポジション」は、優れた F# コードが遵守できる長年にわたる慣用句です。 基本原則は、基底クラスを公開せずに、呼び出し元がその基底クラスから継承して機能を取得する必要があることです。
クラスが不要な場合は、オブジェクト式を使用してインターフェイスを実装する
オブジェクト式を使用すると、インターフェイスを実行中に実装し、実装されたインターフェイスを値にバインドできます。これをクラス内で行う必要はありません。 これは特に、インターフェイス "のみ" を実装する必要があり、完全なクラスを必要としない場合に便利です。
たとえば、次に示すのは、open
ステートメントのないシンボルを追加した場合にコード修正アクションを提供するために Ionide で実行されるコードです。
let private createProvider () =
{ new CodeActionProvider with
member this.provideCodeActions(doc, range, context, ct) =
let diagnostics = context.diagnostics
let diagnostic = diagnostics |> Seq.tryFind (fun d -> d.message.Contains "Unused open statement")
let res =
match diagnostic with
| None -> [||]
| Some d ->
let line = doc.lineAt d.range.start.line
let cmd = createEmpty<Command>
cmd.title <- "Remove unused open"
cmd.command <- "fsharp.unusedOpenFix"
cmd.arguments <- Some ([| doc |> unbox; line.range |> unbox; |] |> ResizeArray)
[|cmd |]
res
|> ResizeArray
|> U2.Case1
}
Visual Studio Code API を操作するときにはクラスは不要であるため、オブジェクト式はこのための最適なツールです。 また、テスト ルーチンを使用してインターフェイスを即席でスタブする必要がある場合に、単体テストにも役立ちます。
シグネチャを短縮するために型略称を検討する
型略称は、関数シグネチャやより複雑な型など、ラベルを別の型に割り当てるのに便利な方法です。 たとえば、次の別名は、ディープ ラーニング ライブラリである CNTK で計算を定義するために必要なラベルを割り当てます。
open CNTK
// DeviceDescriptor, Variable, and Function all come from CNTK
type Computation = DeviceDescriptor -> Variable -> Function
名前 Computation
は、別名を定義しているシグネチャに一致する任意の関数を示す便利な方法です。 このような型略称を使用すると便利で、より簡潔なコードを記述できます。
ドメインを表すために型略称を使用しない
型略称は関数シグネチャに名前を付ける場合に便利ですが、他の型を省略すると混乱を招く可能性があります。 次の略称を考えてみます。
// Does not actually abstract integers.
type BufferSize = int
これはさまざまな意味で混乱を招く可能性があります。
BufferSize
は抽象化ではなく、整数の別の名前にすぎません。BufferSize
がパブリック API で公開されている場合、単なるint
ではない意味に誤って解釈される可能性があります。 通常、ドメイン型には複数の属性があり、int
のようなプリミティブ型ではありません。 この略称は、このような前提に違反します。BufferSize
(パスカル ケース) の大文字と小文字の区別は、この型がより多くのデータを保持しているという意味を含んでいます。- 関数に名前付き引数を指定する場合と比べて、この別名ではわかりやすさが向上していません。
- 略称はコンパイルされた IL のマニフェストを持ちません。これは単なる整数であり、この別名はコンパイル時の構成要素です。
module Networking =
...
let send data (bufferSize: int) = ...
要約すると、型略称の落とし穴は、それらが省略している型の抽象化ではないということです。 前の例では、BufferSize
は、余分なデータを含まず、int
に既にあるもの以外には型システムの利点のない、被覆された int
にすぎません。
型略称を使用してドメインを表す別の方法として、単一ケースの判別共用体を使用する方法があります。 前のサンプルは次のようにモデル化できます。
type BufferSize = BufferSize of int
BufferSize
とその基になる値の観点で動作するコードを記述する場合は、任意の整数を渡すのではなく、型を構築する必要があります。
module Networking =
...
let send data (BufferSize size) =
...
これにより、関数の呼び出し前に値をラップするために呼び出し元が BufferSize
型を構築する必要があるため、任意の整数を誤って send
関数に渡す可能性が低くなります。
.NET