영어로 읽기

다음을 통해 공유


F# 코딩 규칙

다음 규칙은 대규모 F# 코드베이스로 작업한 경험에서 공식화됩니다. 좋은 F# 코드의 5가지 원칙은 각 권장 사항의 기초입니다. F# 구성 요소 디자인 지침관련이 있지만 라이브러리와 같은 구성 요소뿐만 아니라 모든 F# 코드에 적용할 수 있습니다.

코드 구성

F#에는 코드를 구성하는 두 가지 기본 방법인 모듈 및 네임스페이스가 있습니다. 유사하지만 다음과 같은 차이점이 있습니다.

  • 네임스페이스는 .NET 네임스페이스로 컴파일됩니다. 모듈은 정적 클래스로 컴파일됩니다.
  • 네임스페이스는 항상 최상위 수준입니다. 모듈은 최상위 수준일 수 있으며 다른 모듈 내에 중첩될 수 있습니다.
  • 네임스페이스는 여러 파일에 걸쳐 있습니다. 모듈은 사용할 수 없습니다.
  • 모듈은 다음과 함께 [<RequireQualifiedAccess>] [<AutoOpen>]데코레이팅할 수 있습니다.

다음 지침은 이러한 지침을 사용하여 코드를 구성하는 데 도움이 됩니다.

최상위 수준에서 네임스페이스 선호

공개적으로 사용할 수 있는 코드의 경우 네임스페이스는 최상위 수준의 모듈에 우선적으로 적용됩니다. .NET 네임스페이스로 컴파일되므로 C# using static에서 사용할 수 있습니다.

// Recommended.
namespace MyCode

type MyClass() =
    ...

최상위 모듈을 사용하는 것은 F#에서만 호출될 때 다르게 나타나지 않을 수 있지만 C# 소비자의 경우 특정 using static C# 구문을 인식하지 못할 때 호출자는 모듈을 MyCode 한정 MyClass 해야 하는 것에 놀랄 수 있습니다.

// 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 type더 멀리 떨어진 것으로 참조open)을 포함하여 open 중요합니다. 이는 파일에서 해당 문의 순서와 독립적이며 효과가 using 있는 C#과 using static 는 다릅니다.

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. 이는 더 큰 코드베이스에서 유지 관리하기 어렵습니다.

둘째, 정적으로 초기화된 데이터에는 구성 요소 자체에서 여러 스레드를 사용하는 경우 스레드로부터 안전하지 않은 값이 포함되지 않아야 합니다. 이는 분명히 .에 의해 dep3위반됩니다.

마지막으로 모듈 초기화는 전체 컴파일 단위에 대한 정적 생성자로 컴파일됩니다. 해당 모듈의 바운드 값 초기화에서 오류가 발생하면 애플리케이션의 전체 수명 동안 캐시되는 것으로 TypeInitializationException 나타납니다. 이것은 진단하기 어려울 수 있습니다. 일반적으로 추론을 시도할 수 있는 내부 예외가 있지만, 그렇지 않은 경우 근본 원인을 알 수 없습니다.

대신 간단한 클래스를 사용하여 종속성을 유지합니다.

type MyParametricApi(dep1, dep2, dep3) =
    member _.Function1 arg1 = doStuffWith dep1 dep2 dep3 arg1
    member _.Function2 arg2 = doStuffWith dep1 dep2 dep3 arg2

이를 통해 다음을 수행할 수 있습니다.

  1. 종속 상태를 API 자체 외부로 푸시합니다.
  2. 이제 API 외부에서 구성을 수행할 수 있습니다.
  3. 종속 값의 초기화 오류는 로 매니페스트 TypeInitializationException할 가능성이 없습니다.
  4. 이제 API를 더 쉽게 테스트할 수 있습니다.

오류 관리

대규모 시스템의 오류 관리는 복잡하고 미묘한 노력이며 시스템이 내결함성이 있고 잘 작동하도록 보장하는 데는 실버 글머리 기호가 없습니다. 다음 지침은 이 어려운 공간을 탐색하는 지침을 제공해야 합니다.

도메인에 내장된 형식에서 오류 사례 및 잘못된 상태를 나타냅니다.

구분된 공용 구조체를 사용하면 F#을 사용하면 형식 시스템에서 잘못된 프로그램 상태를 나타낼 수 있습니다. 예시:

type MoneyWithdrawalResult =
    | Success of amount:decimal
    | InsufficientFunds of balance:decimal
    | CardExpired of DateTime
    | UndisclosedFailure

이 경우 은행 계좌에서 돈을 인출하는 데 실패할 수 있는 세 가지 알려진 방법이 있습니다. 각 오류 사례는 형식으로 표시되므로 프로그램 전체에서 안전하게 처리할 수 있습니다.

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"

일반적으로 도메인에서 오류가 발생할 수 있는 다양한 방법을 모델링할 수 있는 경우 오류 처리 코드는 더 이상 일반 프로그램 흐름 외에도 처리해야 하는 것으로 처리되지 않습니다. 그것은 단순히 정상적인 프로그램 흐름의 일부이며 예외적으로 간주되지 않습니다. 다음과 같은 두 가지 주요 이점이 있습니다.

  1. 시간이 지남에 따라 도메인이 변경되면 유지 관리가 더 쉽습니다.
  2. 오류 사례는 단위 테스트가 더 쉽습니다.

오류를 형식으로 나타낼 수 없는 경우 예외 사용

문제 도메인에서 모든 오류를 나타낼 수 있는 것은 아닙니다. 이러한 종류의 오류는 본질적으로 예외이므로 F#에서 예외를 발생시키고 catch할 수 있습니다.

먼저 예외 디자인 지침을는 것이 좋습니다. F#에도 적용할 수 있습니다.

예외를 발생시키기 위해 F#에서 사용할 수 있는 기본 구문은 다음 기본 설정 순서로 고려해야 합니다.

함수 구문 목적
nullArg nullArg "argumentName" 지정된 인수 이름을 사용하여 System.ArgumentNullException a를 발생합니다.
invalidArg invalidArg "argumentName" "message" System.ArgumentException 지정된 인수 이름 및 메시지를 사용하여 a를 발생합니다.
invalidOp invalidOp "message" 지정된 메시지와 System.InvalidOperationException 함께 발생합니다.
raise raise (ExceptionType("message")) 예외를 throw하기 위한 범용 메커니즘입니다.
failwith failwith "message" 지정된 메시지와 System.Exception 함께 발생합니다.
failwithf failwithf "format string" argForFormatString System.Exception 형식 문자열 및 해당 입력에 의해 결정되는 메시지와 함께 발생합니다.

invalidOp invalidArg사용하고nullArg, throwArgumentNullException하는 메커니즘으로, ArgumentException그리고 InvalidOperationException 적절한 경우 사용합니다.

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

코드를 깨끗하게 유지하려는 경우 패턴 일치를 사용하여 예외가 발생할 경우 수행할 기능을 조정하는 것이 약간 어려울 수 있습니다. 이를 처리하는 한 가지 방법은 예외 자체를 사용하여 오류 사례를 둘러싼 기능을 그룹화하기 위한 수단으로 활성 패턴을 사용하는 것입니다. 예를 들어 예외를 throw할 때 예외 메타데이터에 중요한 정보를 묶는 API를 사용할 수 있습니다. 활성 패턴 내에서 캡처된 예외의 본문에서 유용한 값을 래핑 해제하고 해당 값을 반환하면 경우에 따라 유용할 수 있습니다.

monadic 오류 처리를 사용하여 예외를 대체하지 마세요.

예외는 종종 순수한 기능 패러다임에서 금기로 간주됩니다. 실제로 예외는 순도를 위반하므로 기능적으로 순수하지 않다고 생각하는 것이 안전합니다. 그러나 코드가 실행되어야 하는 위치의 현실을 무시하고 런타임 오류가 발생할 수 있습니다. 일반적으로 대부분의 항목이 순수하거나 합계가 아니라는 가정하에 코드를 작성하여 불쾌한 놀라움을 최소화합니다(C#에서 비어 catch 있거나 스택 추적을 잘못 관리하거나 정보를 삭제하는 것과 비슷합니다).

.NET 런타임 및 언어 간 에코시스템 전체의 관련성 및 적합성과 관련하여 예외의 다음 핵심 강점/측면을 고려하는 것이 중요합니다.

  • 여기에는 문제를 디버깅할 때 유용한 자세한 진단 정보가 포함되어 있습니다.
  • 런타임 및 기타 .NET 언어로 잘 이해됩니다.
  • 임시로 의미 체계의 일부 하위 집합을 구현하여 예외를 방지수 없는 코드와 비교할 때 상당한 상용구가 감소할 수 있습니다.

이 세 번째 점은 중요합니다. 사소한 복잡한 작업의 경우 예외를 사용하지 않으면 다음과 같은 구조를 처리할 수 있습니다.

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?

또한 "더 좋은" 형식을 반환하는 "simple" 함수를 원하는 경우 예외를 삼키는 것이 좋습니다.

// Can be problematic due to discarding the cause of error.
let tryReadAllText (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with _ -> None

아쉽게도 tryReadAllText 파일 시스템에서 발생할 수 있는 수많은 작업을 기반으로 수많은 예외를 throw할 수 있으며, 이 코드는 사용자 환경에서 실제로 문제가 발생할 수 있는 사항에 대한 정보를 삭제합니다. 이 코드를 결과 형식으로 바꾸면 "문자열 형식" 오류 메시지 구문 분석으로 돌아갑니다.

// 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의 호출자로 처리할 수 없는 것으로 악명이 높습니다.

위의 예제에 대한 좋은 대안은 특정 예외를 catch하고 해당 예외의 컨텍스트에서 의미 있는 값을 반환하는 것입니다. 다음과 같이 함수를 tryReadAllText 수정하는 경우 더 많은 의미가 있습니다. None

let tryReadAllTextIfPresent (path : string) =
    try System.IO.File.ReadAllText path |> Some
    with :? FileNotFoundException -> None

이제 이 함수는 catch-all로 작동하는 대신 파일을 찾을 수 없을 때 사례를 제대로 처리하고 반환에 해당 의미를 할당합니다. 이 반환 값은 컨텍스트 정보를 삭제하지 않거나 호출자가 코드의 해당 시점에서 관련이 없는 사례를 처리하도록 강요하지 않으면서 해당 오류 사례에 매핑될 수 있습니다.

형식 Result<'Success, 'Error> 은 중첩되지 않은 기본 작업에 적합하며, F# 선택적 형식은 무언가 또는 아무것도 반환할 수 없는 경우를 나타내는 데 적합합니다. 그러나 예외를 대체하는 것은 아니며 예외를 바꾸려는 시도에서 사용하면 안 됩니다. 대신 예외 및 오류 관리 정책의 특정 측면을 대상으로 하는 방식으로 해결하기 위해 신중하게 적용해야 합니다.

부분 애플리케이션 및 지점 없는 프로그래밍

F#은 부분 애플리케이션을 지원하므로 포인트 없는 스타일로 프로그래밍하는 다양한 방법을 지원합니다. 이는 모듈 또는 무언가의 구현 내에서 코드를 다시 사용하는 데 도움이 될 수 있지만 공개적으로 노출하는 것은 아닙니다. 일반적으로 포인트 프리 프로그래밍은 그 자체로 미덕이 아니며 스타일에 몰두하지 않는 사람들에게 중요한 인지 장벽을 추가할 수 있습니다.

공용 API에서 부분 애플리케이션 및 커리를 사용하지 마세요.

거의 예외가 없으므로 공용 API에서 부분 애플리케이션을 사용하는 것은 소비자에게 혼란스러울 수 있습니다. 일반적으로 letF# 코드의 -bound 값은 함수 값이 아닌 입니다. 값과 함수 값을 함께 혼합하면 특히 함수 작성과 같은 >> 연산자와 결합된 경우 상당한 인지 오버헤드를 대가로 몇 줄의 코드가 저장될 수 있습니다.

포인트 없는 프로그래밍에 대한 도구 의미를 고려합니다.

Curried 함수는 인수에 레이블을 지정하지 않습니다. 여기에는 도구에 영향을 줍니다. 다음 두 함수를 고려합니다.

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
        ...

단위 테스트 Transactions.doTransaction ImplementationLogic.Tests.fsproj 는 간단합니다.

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)

이 기술을 전체 코드베이스에 보편적으로 적용하지 마세요. 하지만 복잡한 내부 및 해당 내부 단위 테스트의 상용구 수를 줄이는 것이 좋습니다.

Access Control

F#에는 .NET 런타임에서 사용할 수 있는 항목에서 상속된 액세스 제어에 대한 여러 옵션이 있습니다. 이는 형식에만 사용할 수 있는 것이 아니라 함수에도 사용할 수 있습니다.

널리 사용되는 라이브러리 컨텍스트의 모범 사례:

  • 공개적으로 사용할 수 있어야 할 때까지 형식public 및 멤버가 아닌 멤버를 선호합니다. 이렇게 하면 소비자가 결합하는 항목도 최소화됩니다.
  • 모든 도우미 기능을 private유지합니다.
  • 도우미 함수가 [<AutoOpen>] 많은 경우 도우미 함수의 프라이빗 모듈을 사용하는 것이 좋습니다.

형식 유추 및 제네릭

형식 유추를 사용하면 많은 상용구 입력을 줄일 수 있습니다. 또한 F# 컴파일러의 자동 일반화는 추가 작업 없이 더 많은 제네릭 코드를 작성하는 데 도움이 될 수 있습니다. 그러나 이러한 기능은 보편적으로 좋지 않습니다.

  • 공용 API에서 명시적 형식을 사용하여 인수 이름에 레이블을 지정하는 것이 좋습니다. 이에 대한 형식 유추를 사용하지 마세요.

    그 이유는 컴파일러가 아닌 API의 모양을 제어해야 하기 때문입니다. 컴파일러가 형식을 유추할 때 좋은 작업을 수행할 수 있지만 사용하는 내부가 형식을 변경한 경우 API의 모양을 변경할 수 있습니다. 이는 원하는 것일 수 있지만 다운스트림 소비자가 처리해야 하는 호환성이 손상되는 API 변경이 거의 확실하게 발생합니다. 대신 공용 API의 모양을 명시적으로 제어하는 경우 이러한 호환성이 손상되는 변경 내용을 제어할 수 있습니다. DDD 용어에서 이것은 부패 방지 계층으로 간주될 수 있습니다.

  • 제네릭 인수에 의미 있는 이름을 지정하는 것이 좋습니다.

    특정 도메인과 관련이 없는 진정한 제네릭 코드를 작성하지 않는 한 의미 있는 이름은 다른 프로그래머가 작업 중인 도메인을 이해하는 데 도움이 될 수 있습니다. 예를 들어 문서 데이터베이스와 상호 작용하는 컨텍스트에서 명명된 'Document 형식 매개 변수를 사용하면 작업 중인 함수 또는 멤버가 제네릭 문서 형식을 수락할 수 있음을 더 명확하게 알 수 있습니다.

  • PascalCase를 사용하여 제네릭 형식 매개 변수의 이름을 지정하는 것이 좋습니다.

    이는 .NET에서 작업을 수행하는 일반적인 방법이므로 snake_case 또는 camelCase 대신 PascalCase를 사용하는 것이 좋습니다.

마지막으로 자동 일반화가 F# 또는 대규모 코드베이스를 접하는 사용자에게 항상 도움이 되는 것은 아닙니다. 일반적인 구성 요소를 사용하는 경우 인지 오버헤드가 있습니다. 또한 자동으로 일반화된 함수가 다른 입력 형식과 함께 사용되지 않는 경우(이러한 함수를 사용하도록 의도된 경우는 물론) 제네릭이 되는 것은 실질적인 이점이 없습니다. 작성 중인 코드가 실제로 제네릭이 되는 것이 도움이 되는지 항상 고려합니다.

성능

할당 속도가 높은 작은 형식의 구조체 고려

일반적으로 개체 할당을 방지하므로 구조체(값 형식이라고도 함)를 사용하면 일부 코드의 성능이 더 높아질 수 있습니다. 그러나 구조체가 항상 "더 빠르게" 단추가 되는 것은 아닙니다. 구조체의 데이터 크기가 16바이트를 초과하면 데이터를 복사하면 참조 형식을 사용하는 것보다 CPU 시간이 더 많이 소요될 수 있습니다.

구조체를 사용해야 하는지 여부를 확인하려면 다음 조건을 고려합니다.

  • 데이터 크기가 16바이트 이하인 경우
  • 실행 중인 프로그램에서 메모리에 이러한 형식의 인스턴스가 많이 있을 가능성이 있는 경우

첫 번째 조건이 적용되는 경우 일반적으로 구조체를 사용해야 합니다. 둘 다 적용되는 경우 거의 항상 구조체를 사용해야 합니다. 이전 조건이 적용되는 경우도 있지만 구조체를 사용하는 것이 참조 형식을 사용하는 것보다 더 좋거나 나쁘지는 않지만 드물게 발생할 수 있습니다. 하지만 이와 같이 변경할 때는 항상 측정해야 하며 가정이나 직관에 따라 작동하지 않는 것이 중요합니다.

할당 속도가 높은 작은 값 형식을 그룹화할 때 구조체 튜플을 고려합니다.

다음 두 함수를 고려합니다.

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)

BenchmarkDotNetrunWithStructTuple과 같은 통계 벤치마킹 도구를 사용하여 이러한 함수를 벤치마킹하면 구조체 튜플을 사용하는 함수가 40% 더 빠르게 실행되고 메모리가 할당되지 않습니다.

그러나 이러한 결과가 사용자 고유의 코드에서 항상 해당되는 것은 아닙니다. 함수를 표시 inline하면 참조 튜플을 사용하는 코드에서 몇 가지 추가 최적화가 수행되거나 할당할 코드가 단순히 최적화될 수 있습니다. 성능이 우려되는 경우 항상 결과를 측정해야 하며 가정이나 직관에 따라 작동하지 않아야 합니다.

형식이 작고 할당 속도가 높은 경우 구조체 레코드를 고려합니다.

앞에서 설명한 thumb 규칙도 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)

이전 튜플 코드와 비슷하지만 이번에는 예제에서 레코드와 인라인 내부 함수를 사용합니다.

BenchmarkDotNetprocessStructPoint과 같은 통계 벤치마킹 도구를 사용하여 이러한 함수를 벤치마킹하면 거의 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

도메인 모델링을 위해 이와 같은 단일 사례 차별 공용 구조체를 정의하는 것이 일반적입니다. BenchmarkDotNetstructReverseName과 같은 통계 벤치마킹 도구를 사용하여 이러한 함수를 벤치마킹하면 작은 문자열보다 reverseName 약 25% 더 빠르게 실행됩니다. 큰 문자열의 경우 둘 다 동일한 작업을 수행합니다. 따라서 이 경우 구조체를 사용하는 것이 항상 좋습니다. 앞에서 설명한 것처럼 항상 가정이나 직관에 따라 측정하고 작동하지 않습니다.

이전 예제에서는 구조체 구분 공용 구조체의 성능이 더 나은 것으로 나타났지만 도메인을 모델링할 때 더 큰 차별 공용 구조체가 있는 것이 일반적입니다. 더 많은 복사가 포함될 수 있으므로 이러한 더 큰 데이터 형식은 작업에 따라 구조체인 경우에도 잘 수행되지 않을 수 있습니다.

불변성 및 돌연변이

F# 값은 기본적으로 변경할 수 없으므로 특정 버그 클래스(특히 동시성 및 병렬 처리와 관련된 버그)를 방지할 수 있습니다. 그러나 특정 경우에 실행 시간 또는 메모리 할당의 최적(또는 합리적인) 효율성을 달성하기 위해 상태의 내부 변형을 사용하여 작업 범위를 가장 잘 구현할 수 있습니다. 이는 키워드가 있는 F#을 사용하여 옵트인(opt-in)으로 mutable 가능합니다.

F#에서의 mutable 사용은 기능적 순도와 어긋나게 느껴질 수 있습니다. 이것은 이해할 수 있지만, 모든 곳에서 기능 순도는 성능 목표와 확률에있을 수 있습니다. 절충안은 호출자가 함수를 호출할 때 발생하는 일에 대해 신경 쓰지 않아도 되도록 돌연변이를 캡슐화하는 것입니다. 이렇게 하면 성능에 중요한 코드에 대한 변형 기반 구현을 통해 기능 인터페이스를 작성할 수 있습니다.

또한 F# let 바인딩 구문을 사용하면 바인딩을 다른 바인딩에 중첩할 수 있습니다. 이를 활용하여 변수 범위를 mutable 가깝거나 이론적으로 가장 작은 값으로 유지할 수 있습니다.

let data =
    [
        let mutable completed = false
        while not completed do
            logic ()
            // ...
            if someCondition then
                completed <- true   
    ]

바운드 바운드 값을 초기화하는 data 데만 사용된 변경 가능한 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 는 기본 변형 기반 데이터 구조를 캡슐화하므로 호출자가 기본 데이터 구조를 유지하도록 강제하지 않습니다. 클래스는 호출자에게 세부 정보를 노출하지 않고 변형 기반인 데이터 및 루틴을 캡슐화하는 강력한 방법입니다.

다음을 선호 let mutable 합니다. ref

참조 셀은 값 자체가 아닌 값에 대한 참조를 나타내는 방법입니다. 성능에 중요한 코드에 사용할 수 있지만 권장되지는 않습니다. 다음 예시를 참조하세요.

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을 반환하거나 수락하며 몇 가지 일반적인 API를 반환합니다. 배열 및 문자열과 같은 NET 선언 형식은 null을 허용합니다. 그러나 F# 프로그래밍에서 값의 null 발생은 매우 드물며 F#을 사용할 경우의 이점 중 하나는 대부분의 경우 null 참조 오류를 방지하는 것입니다.

특성 사용 AllowNullLiteral 방지

기본적으로 F#으로 선언된 형식은 리터럴 사용을 null 지원하지 않습니다. 이를 허용하도록 F# 형식 AllowNullLiteral 에 수동으로 주석을 달 수 있습니다. 그러나 이 작업을 피하는 것이 거의 항상 더 좋습니다.

특성 사용 Unchecked.defaultof<_> 방지

를 사용하여 F# 형식에 대해 초기화되지 않은 값을 생성하거나 0으로 생성 null 할 수 있습니다 Unchecked.defaultof<_>. 이는 일부 데이터 구조 또는 일부 고성능 코딩 패턴 또는 상호 운용성에 대한 스토리지를 초기화할 때 유용할 수 있습니다. 그러나 이 구문의 사용은 피해야 합니다.

특성 사용 DefaultValue 방지

기본적으로 F# 레코드 및 개체는 생성 시 제대로 초기화되어야 합니다. 이 DefaultValue 특성을 사용하여 개체의 일부 필드를 초기화 값 또는 0으로 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 개념을 사용하는 것이 이상적이지는 않습니다. 다음 목록에서는 상위 수준의 OO 기능 범주에 대한 지침을 제공합니다.

다음과 같은 여러 상황에서 이러한 기능을 사용하는 것이 좋습니다.

  • 점 표기법(x.Length)
  • 인스턴스 멤버
  • 암시적 생성자
  • 정적 멤버
  • 속성을 정의하여 인덱서 표기 Item 법(arr[x])
  • 멤버를 정의하여 표기법(arr[x..y], arr[x..],arr[..y]) 조각화 GetSlice
  • 명명된 인수 및 선택적 인수
  • 인터페이스 및 인터페이스 구현

먼저 이러한 기능에 도달하지 말고 문제를 해결하는 데 편리할 때 신중하게 적용합니다.

  • 메서드 오버로드
  • 캡슐화된 변경 가능한 데이터
  • 형식의 연산자
  • 자동 속성
  • 구현 및 IDisposableIEnumerable
  • 형식 확장
  • 이벤트
  • 구조체
  • 대리자
  • 열거형

일반적으로 다음 기능을 사용해야 하는 경우가 아니면 이러한 기능을 사용하지 않습니다.

  • 상속 기반 형식 계층 구조 및 구현 상속
  • 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 은 추상화가 아닙니다. 정수의 또 다른 이름입니다.
  • 공용 API에 노출되는 경우 BufferSize 단순히 int그 이상을 의미하는 것으로 쉽게 잘못 해석될 수 있습니다. 일반적으로 도메인 형식에는 여러 특성이 있으며 다음과 같은 int기본 형식은 아닙니다. 이 약어는 해당 가정을 위반합니다.
  • 대/소문 BufferSize 자(PascalCase)는 이 형식이 더 많은 데이터를 보유한다는 것을 의미합니다.
  • 이 별칭은 함수에 명명된 인수를 제공하는 것에 비해 향상된 명확성을 제공하지 않습니다.
  • 약어는 컴파일된 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 수를 함수에 실수로 전달할 가능성이 줄어듭니다.