F# 코드 포맷팅 가이드라인
F# 코드 포맷팅 가이드라인
F# 코드를 어떤 모양으로 써 내려가야 할지, 팀 안에서 '우리는 이렇게 쓴다'고 합의할 기준이 필요할 때가 있어요. 이 글은 그 기준을 세우기 위한 지침이에요. 목표는 단순해요. 코드를 더 읽기 좋게, Visual Studio Code 같은 포맷팅 도구가 기본으로 적용하는 관례와 맞게, 그리고 온라인에서 흔히 보는 다른 F# 코드와 비슷한 모습으로 쓰는 거예요.
출처: F# code formatting guidelines - Microsoft Learn
본문
자동 코드 포맷팅
Fantomas 코드 포맷터는 F# 커뮤니티에서 자동 포맷팅의 표준으로 쓰이는 도구예요. Fantomas의 기본 설정이 바로 이 스타일 가이드에 맞춰져 있죠.
이 포맷터 사용을 강력히 권장해요. F# 팀에서는 코드 포맷팅 규칙을 합의해서, 팀 저장소에 커밋해 둔 설정 파일로 문서화(codified)하는 방식을 쓰는 게 좋아요.
포맷팅의 일반 규칙
F#은 기본적으로 의미 있는 공백(significant white space) 을 사용하는, 공백에 민감한 언어예요. 아래 지침은 이 특성이 만들어내는 여러 까다로운 상황을 어떻게 처리할지 안내하기 위한 거예요.
탭 대신 공백을 써요
들여쓰기가 필요하면 반드시 공백을 써야 해요. F# 코드는 탭을 쓰지 않아요. 문자열 리터럴이나 주석 바깥에서 탭 문자가 나오면 컴파일러가 오류를 내줘요.
들여쓰기는 일관되게
들여쓰기할 때는 최소 한 칸의 공백이 필요해요. 각 팀이 코딩 표준으로 들여쓰기 칸 수를 정할 수 있는데, 보통 들여쓰기가 일어나는 단계마다 2칸, 3칸, 4칸 중 하나를 써요. 저는 5칸 단계당 4칸을 권장해요.
물론 프로그램 들여쓰기는 주관적인 부분이 있어요. 약간씩 다른 스타일도 괜찮지만, 무엇보다 지켜야 할 첫 번째 규칙은 일관성이에요. 보편적으로 받아들여지는 스타일을 하나 골라서 코드베이스 전체에 체계적으로 적용하세요.
이름 길이에 민감한 포맷은 피해요
이름 길이에 따라 들여쓰기나 정렬이 변하는 포맷은 피하는 게 좋아요.
// ✔️ OK
let myLongValueName =
someExpression
|> anotherExpression
// ❌ Not OK
let myLongValueName = someExpression
|> anotherExpression
// ✔️ OK
let myOtherVeryLongValueName =
match
someVeryLongExpressionWithManyParameters
parameter1
parameter2
parameter3
with
| Some _ -> ()
| ...
// ❌ Not OK
let myOtherVeryLongValueName =
match someVeryLongExpressionWithManyParameters parameter1
parameter2
parameter3 with
| Some _ -> ()
| ...
// ❌ Still Not OK
let myOtherVeryLongValueName =
match someVeryLongExpressionWithManyParameters
parameter1
parameter2
parameter3 with
| Some _ -> ()
| ...
이런 포맷을 피해야 하는 주된 이유는 세 가지예요.
- 중요한 코드가 화면 오른쪽으로 너무 밀려나요.
- 실제 코드가 쓸 수 있는 가로 폭이 줄어들어요.
- 이름을 바꾸면 정렬이 깨질 수 있어요.
불필요한 공백은 피해요
이 스타일 가이드에 설명한 경우를 제외하고는 F# 코드에 불필요한 공백을 넣지 마세요.
// ✔️ OK
spam (ham 1)
// ❌ Not OK
spam ( ham 1 )
주석 포맷팅
여러 줄에 걸쳐 생각을 표현하고 싶다면, 블록 주석보다는 여러 개의 이중 슬래시(//) 주석을 선호해요.
// Prefer this style of comments when you want
// to express written ideas on multiple lines.
(*
Block comments can be used, but use sparingly.
They are useful when eliding code sections.
*)
주석은 첫 글자를 대문자로 시작하고, 잘 구성된 어구나 문장이어야 해요.
// ✔️ A good comment.
let f x = x + 1 // Increment by one.
// ❌ two poor comments
let f x = x + 1 // plus one
XML 문서 주석 포맷팅은 아래 "선언 포맷팅" 절을 참고하세요.
표현식 포맷팅
이 절에서는 여러 종류의 표현식을 어떻게 포맷하는지 다뤄요.
문자열 표현식 포맷팅
문자열 리터럴과 보간 문자열은 줄이 아무리 길어도 한 줄에 그냥 두면 돼요.
let serviceStorageConnection =
$"DefaultEndpointsProtocol=https;AccountName=%s{serviceStorageAccount.Name};AccountKey=%s{serviceStorageAccountKey.Value}"
여러 줄로 나누는 보간 표현식은 권장하지 않아요. 그 대신 표현식의 결과를 값에 바인딩해서, 그 값을 보간 문자열에 사용하는 게 좋아요.
튜플 표현식 포맷팅
튜플 인스턴스화는 괄호로 감싸고, 그 안의 구분 쉼표 뒤에는 공백 하나를 넣어요. 예를 들어 (1, 2), (x, y, z) 같은 형태예요.
// ✔️ OK
let pair = (1, 2)
let triples = [ (1, 2, 3); (11, 12, 13) ]
튜플을 패턴 매칭할 때 괄호를 생략하는 것은 보편적으로 인정되는 방식이에요.
// ✔️ OK
let (x, y) = z
let x, y = z
// ✔️ OK
match x, y with
| 1, _ -> 0
| x, 1 -> 0
| x, y -> 1
튜플이 함수의 반환값일 때 괄호를 생략하는 것도 보편적으로 인정돼요.
// ✔️ OK
let update model msg =
match msg with
| 1 -> model + 1, []
| _ -> model, [ msg ]
정리하면, 튜플 인스턴스화는 괄호를 쓰는 걸 선호하되, 패턴 매칭이나 반환값으로 쓸 때는 괄호를 생략해도 괜찮아요.
함수·메서드 적용(application) 표현식 포맷팅
함수나 메서드를 적용할 때는, 줄 폭이 충분하면 인자들을 같은 줄에 둬요.
// ✔️ OK
someFunction1 x.IngredientName x.Quantity
인자에 괄호가 꼭 필요하지 않으면 괄호를 생략해요.
// ✔️ OK
someFunction1 x.IngredientName
// ❌ Not preferred - parentheses should be omitted unless required
someFunction1 (x.IngredientName)
// ✔️ OK - parentheses are required
someFunction1 (convertVolumeToLiter x)
커리된 인자를 여러 개 사용해 호출할 때는 공백을 빼먹지 마세요.
// ✔️ OK
someFunction1 (convertVolumeToLiter x) (convertVolumeUSPint x)
someFunction2 (convertVolumeToLiter y) y
someFunction3 z (convertVolumeUSPint z)
// ❌ Not preferred - spaces should not be omitted between arguments
someFunction1(convertVolumeToLiter x)(convertVolumeUSPint x)
someFunction2(convertVolumeToLiter y) y
someFunction3 z(convertVolumeUSPint z)
기본 포맷팅 관례에서는 소문자로 시작하는 함수를 튜플이나 괄호 인자에 적용할 때(인자가 하나뿐이어도) 공백을 하나 넣어요.
// ✔️ OK
someFunction2 ()
// ✔️ OK
someFunction3 (x.Quantity1 + x.Quantity2)
// ❌ Not OK, formatting tools will add the extra space by default
someFunction2()
// ❌ Not OK, formatting tools will add the extra space by default
someFunction3(x.IngredientName, x.Quantity)
반대로 기본 포맷팅 관례에서는 대문자로 시작하는 메서드를 튜플 인자에 적용할 때는 공백을 넣지 않아요. 이런 메서드는 플루언트(Fluent) 프로그래밍에 자주 쓰이기 때문이에요.
// ✔️ OK - Methods accepting parenthesize arguments are applied without a space
SomeClass.Invoke()
// ✔️ OK - Methods accepting tuples are applied without a space
String.Format(x.IngredientName, x.Quantity)
// ❌ Not OK, formatting tools will remove the extra space by default
SomeClass.Invoke ()
// ❌ Not OK, formatting tools will remove the extra space by default
String.Format (x.IngredientName, x.Quantity)
이름의 마지막 부분이 공백을 넣을지 말지를 결정해요. 이름의 앞부분은 아무 영향이 없어요.
// ✔️ OK - `convertTo` is lower-case, so the space is added
Volume.Conversion.convertTo (x.Quantity, Liter)
// ✔️ OK - `Contains` is capitalized, so no space is added
recipe.Ingredients.Contains(x.IngredientName)
두 관례 모두, 적용되는 대상 전체가 점으로만 이어진 단순한 이름(plain dotted name) 일 때만 성립해요. 즉 괄호 앞에 호출, 인덱스, 이름이 아닌 리시버(receiver), 타입 적용 같은 것이 오면 공백을 넣지 않아요. 그래야 플루언트 체인이 마지막에 공백이 있는 호출로 끝나지 않게 되죠.
// ✔️ OK - a plain dotted name, however many dots it has
recipe.Ingredients.convertTo (Liter)
// ✔️ OK - a call comes before the parentheses, so no space is added
getRecipe().convertTo(Liter)
// ✔️ OK - an index comes before the parentheses
recipe.Ingredients[0].convertTo(Liter)
// ✔️ OK - a receiver that isn't a name, such as an expression or a literal
(getRecipe x.IngredientName).convertTo(Liter)
// ✔️ OK - a type application, on the call itself or with no dots at all
recipe.convertTo<Liter>(x.Quantity)
unbox<Volume>(x.Quantity)
// ❌ Not OK, formatting tools will remove the extra space by default
getRecipe().convertTo (Liter)
recipe.Ingredients[0].convertTo (Liter)
unbox<Volume> (x.Quantity)
타입 적용 케이스는 괄호가 이미 쓰인 상황에서만 나와요. 포맷팅 도구는 괄호를 추가하지 않으므로, 괄호 없이 쓴 제네릭 적용은 그대로 두게 돼요.
// ✔️ OK
unbox<Volume> x.Quantity
이와 같은 포맷팅 관례는 패턴 매칭에도 그대로 적용돼요. F#은 표현식과 패턴의 포맷이 일관되길 원해요.
// ✔️ OK - Consistent formatting for expressions and patterns
let result = Some(value)
match result with
| Some(x) -> x
| None -> 0
가독성을 위해서, 또는 인자의 목록이나 인자 이름이 너무 길어져서, 함수에 인자를 새 줄로 넘겨야 할 수도 있어요. 그럴 때는 한 단계 들여써요.
// ✔️ OK
someFunction2
x.IngredientName x.Quantity
// ✔️ OK
someFunction3
x.IngredientName1 x.Quantity2
x.IngredientName2 x.Quantity2
// ✔️ OK
someFunction4
x.IngredientName1
x.Quantity2
x.IngredientName2
x.Quantity2
// ✔️ OK
someFunction5
(convertVolumeToLiter x)
(convertVolumeUSPint x)
(convertVolumeImperialPint x)
함수가 여러 줄짜리 튜플 인자 하나를 받는다면, 각 인자를 새 줄에 배치해요.
// ✔️ OK
someTupledFunction (
478815516,
"A very long string making all of this multi-line",
1515,
false
)
// OK, but formatting tools will reformat to the above
someTupledFunction
(478815516,
"A very long string making all of this multi-line",
1515,
false)
인자 표현식이 짧다면, 인자들을 공백으로 구분해 한 줄에 두면 돼요.
// ✔️ OK
let person = new Person(a1, a2)
// ✔️ OK
let myRegexMatch = Regex.Match(input, regex)
// ✔️ OK
let untypedRes = checker.ParseFile(file, source, opts)
인자 표현식이 길다면, 왼쪽 괄호에 맞춰 들여쓰지 말고 새 줄로 넘기고 한 단계 들여써요.
// ✔️ OK
let person =
new Person(
argument1,
argument2
)
// ✔️ OK
let myRegexMatch =
Regex.Match(
"my longer input string with some interesting content in it",
"myRegexPattern"
)
// ✔️ OK
let untypedRes =
checker.ParseFile(
fileName,
sourceText,
parsingOptionsWithDefines
)
// ❌ Not OK, formatting tools will reformat to the above
let person =
new Person(argument1,
argument2)
// ❌ Not OK, formatting tools will reformat to the above
let untypedRes =
checker.ParseFile(fileName,
sourceText,
parsingOptionsWithDefines)
여러 줄짜리 인자가 하나뿐인 경우에도(여러 줄 문자열을 포함해서) 같은 규칙이 적용돼요.
// ✔️ OK
let poemBuilder = StringBuilder()
poemBuilder.AppendLine(
"""
The last train is nearly due
The Underground is closing soon
And in the dark, deserted station
Restless in anticipation
A man waits in the shadows
"""
)
Option.traverse(
create
>> Result.setError [ invalidHeader "Content-Checksum" ]
)
파이프라인 표현식 포맷팅
여러 줄을 사용할 때는 파이프라인 |> 연산자가 그 연산을 적용받는 표현식의 아래에 와야 해요.
// ✔️ OK
let methods2 =
System.AppDomain.CurrentDomain.GetAssemblies()
|> List.ofArray
|> List.map (fun assm -> assm.GetTypes())
|> Array.concat
|> List.ofArray
|> List.map (fun t -> t.GetMethods())
|> Array.concat
// ❌ Not OK, add a line break after "=" and put multi-line pipelines on multiple lines.
let methods2 = System.AppDomain.CurrentDomain.GetAssemblies()
|> List.ofArray
|> List.map (fun assm -> assm.GetTypes())
|> Array.concat
|> List.ofArray
|> List.map (fun t -> t.GetMethods())
|> Array.concat
// ❌ Not OK either
let methods2 = System.AppDomain.CurrentDomain.GetAssemblies()
|> List.ofArray
|> List.map (fun assm -> assm.GetTypes())
|> Array.concat
|> List.ofArray
|> List.map (fun t -> t.GetMethods())
|> Array.concat
역방향 파이프라인 |< 연산자는 짧은 표현식이면 한 줄에 두세요. 줄 길이가 넘칠 때만 인자를 새 줄에 두고, 일관되게 정렬해요.
// ✔️ OK - short expressions stay on one line
let result = someFunction <| arg1 <| arg2 <| arg3
// ✔️ OK - longer expressions can wrap when necessary
failwith
<| sprintf "A very long error message that exceeds reasonable line length: %s - additional details: %s"
longVariableName
anotherLongVariableName
// ✔️ OK - align continuation lines with the operator
let longResult =
someVeryLongFunctionName
<| firstVeryLongArgumentName
<| secondVeryLongArgumentName
<| thirdVeryLongArgumentName
// ❌ Not OK - unnecessary wrapping of short expressions
failwith <| sprintf "short: %s"
value
람다 표현식 포맷팅
람다 표현식이 여러 줄 표현식의 인자로 쓰이면서, 뒤에 다른 인자들이 이어질 때는 람다 본문을 새 줄에, 한 단계 들여쓰기해서 배치해요.
// ✔️ OK
let printListWithOffset a list1 =
List.iter
(fun elem ->
printfn $"A very long line to format the value: %d{a + elem}")
list1
람다 인자가 함수 적용에서 마지막 인자라면, 화살표(->)까지 모든 인자를 같은 줄에 배치해요.
// ✔️ OK
Target.create "Build" (fun ctx ->
// code
// here
())
// ✔️ OK
let printListWithOffsetPiped a list1 =
list1
|> List.map (fun x -> x + 1)
|> List.iter (fun elem ->
printfn $"A very long line to format the value: %d{a + elem}")
function을 쓰는 match 람다도 비슷하게 다뤄요.
// ✔️ OK
functionName arg1 arg2 arg3 (function
| Choice1of2 x -> 1
| Choice2of2 y -> 2)
람다 앞에 오는 인자가 많거나 여러 줄이라면, 모든 인자를 한 단계 들여써요.
// ✔️ OK
functionName
arg1
arg2
arg3
(fun arg4 ->
bodyExpr)
// ✔️ OK
functionName
arg1
arg2
arg3
(function
| Choice1of2 x -> 1
| Choice2of2 y -> 2)
람다 본문이 여러 줄이 된다면, 그 람다를 지역 스코프 함수로 리팩터링하는 걸 고려해 보세요.
파이프라인에 람다 표현식이 포함되면, 각 람다는 보통 파이프라인의 각 단계에서 마지막 인자로 오게 돼요.
// ✔️ OK, with 4 spaces indentation
let printListWithOffsetPiped list1 =
list1
|> List.map (fun elem -> elem + 1)
|> List.iter (fun elem ->
// one indent starting from the pipe
printfn $"A very long line to format the value: %d{elem}")
// ✔️ OK, with 2 spaces indentation
let printListWithOffsetPiped list1 =
list1
|> List.map (fun elem -> elem + 1)
|> List.iter (fun elem ->
// one indent starting from the pipe
printfn $"A very long line to format the value: %d{elem}")
람다의 인자들이 한 줄에 맞지 않거나, 그 자체가 여러 줄이라면, 인자들을 다음 줄에 한 단계 들여써서 배치해요.
// ✔️ OK
fun
(aVeryLongParameterName: AnEquallyLongTypeName)
(anotherVeryLongParameterName: AnotherLongTypeName)
(yetAnotherLongParameterName: LongTypeNameAsWell)
(youGetTheIdeaByNow: WithLongTypeNameIncluded) ->
// code starts here
()
// ❌ Not OK, code formatters will reformat to the above to respect the maximum line length.
fun (aVeryLongParameterName: AnEquallyLongTypeName) (anotherVeryLongParameterName: AnotherLongTypeName) (yetAnotherLongParameterName: LongTypeNameAsWell) (youGetTheIdeaByNow: WithLongTypeNameIncluded) ->
()
// ✔️ OK
let useAddEntry () =
fun
(input:
{| name: string
amount: Amount
isIncome: bool
created: string |}) ->
// foo
bar ()
// ❌ Not OK, code formatters will reformat to the above to avoid reliance on whitespace alignment that is contingent to length of an identifier.
let useAddEntry () =
fun (input: {| name: string
amount: Amount
isIncome: bool
created: string |}) ->
// foo
bar ()
지연(lazy) 표현식 포맷팅
한 줄짜리 lazy 표현식은 모든 것을 한 줄에 두세요.
// ✔️ OK
let x = lazy (computeValue())
// ✔️ OK
let y = lazy (a + b)
여러 줄짜리 lazy 표현식은 여는 괄호를 lazy 키워드와 같은 줄에 두고, 표현식 본문을 한 단계 들여쓰며, 닫는 괄호는 여는 괄호와 맞춰요.
// ✔️ OK
let v =
lazy (
// some code
let x = computeExpensiveValue()
let y = computeAnotherValue()
x + y
)
// ✔️ OK
let handler =
lazy (
let connection = openConnection()
let data = fetchData connection
processData data
)
이건 여러 줄 인자를 가진 다른 함수 적용과 같은 패턴을 따라요. 여는 괄호는 lazy와 함께 있고, 표현식은 한 단계 들여쓰는 거예요.
산술·이항 표현식 포맷팅
이항 산술 표현식 주위에는 항상 공백을 넣어요.
// ✔️ OK
let subtractThenAdd x = x - 1 + 3
이항 - 연산자 주위를 공백으로 감싸지 않으면, 특정 포맷과 결합했을 때 **단항(unary) -**로 해석될 수 있어요. 단항 - 연산자는 항상 부정하는 값 바로 뒤에 이어질 수 있게 해야 해요.
// ✔️ OK
let negate x = -x
// ❌ Not OK
let negateBad x = - x
- 연산자 뒤에 공백 문자를 넣으면 다른 사람에게 혼란을 줄 수 있어요.
이항 연산자는 공백으로 구분하고, 중위(infix) 표현식은 같은 열에 정렬하는 것도 괜찮아요.
// ✔️ OK
let function1 () =
acc +
(someFunction
x.IngredientName x.Quantity)
// ✔️ OK
let function1 arg1 arg2 arg3 arg4 =
arg1 + arg2 +
arg3 + arg4
이 규칙은 타입과 상수 주석의 측정 단위(units of measure) 에도 적용돼요.
// ✔️ OK
type Test =
{ WorkHoursPerWeek: uint<hr / (staff weeks)> }
static member create = { WorkHoursPerWeek = 40u<hr / (staff weeks)> }
// ❌ Not OK
type Test =
{ WorkHoursPerWeek: uint<hr/(staff weeks)> }
static member create = { WorkHoursPerWeek = 40u<hr/(staff weeks)> }
아래 연산자들은 F# 표준 라이브러리에 정의되어 있으니, 같은 기능을 새로 정의하기보다는 이것들을 사용해야 해요. 이 연산자들을 쓰면 코드가 더 읽기 쉽고 관용적(idiomatic)이 되는 경향이 있어요. 다음 목록은 권장되는 F# 연산자들을 정리한 거예요.
// ✔️ OK
x |> f // Forward pipeline
f <| x // Reverse pipeline
f >> g // Forward composition
x |> ignore // Discard away a value
x + y // Overloaded addition (including string concatenation)
x - y // Overloaded subtraction
x * y // Overloaded multiplication
x / y // Overloaded division
x % y // Overloaded modulus
x && y // Lazy/short-cut "and"
x || y // Lazy/short-cut "or"
x <<< y // Bitwise left shift
x >>> y // Bitwise right shift
x ||| y // Bitwise or, also for working with "flags" enumeration
x &&& y // Bitwise and, also for working with "flags" enumeration
x ^^^ y // Bitwise xor, also for working with "flags" enumeration
범위(range) 연산자 표현식 포맷팅
.. 주위에는 어떤 표현식이라도 원자적(atomic)이지 않으면 공백을 넣어요. 정수와 한 단어로 된 식별자는 원자적이라고 봐요.
// ✔️ OK
let a = [ 2..7 ] // integers
let b = [ one..two ] // identifiers
let c = [ ..9 ] // also when there is only one expression
let d = [ 0.7 .. 9.2 ] // doubles
let e = [ 2L .. number / 2L ] // complex expression
let f = [| A.B .. C.D |] // identifiers with dots
let g = [ .. (39 - 3) ] // complex expression
let h = [| 1 .. MyModule.SomeConst |] // not all expressions are atomic
for x in 1..2 do
printfn " x = %d" x
let s = seq { 0..10..100 }
// ❌ Not OK
let a = [ 2 .. 7 ]
let b = [ one .. two ]
이 규칙은 슬라이싱(slicing)에도 적용돼요.
// ✔️ OK
arr[0..10]
list[..^1]
if 표현식 포맷팅
조건문의 들여쓰기는 그 표현식들의 크기와 복잡도에 따라 달라져요. 다음 조건을 만족하면 한 줄에 써요.
cond,e1,e2가 모두 짧을 때.e1과e2가 그 자체로 if/then/else 표현식이 아닐 때.
// ✔️ OK
if cond then e1 else e2
else 표현식이 없을 때는, 전체 표현식을 한 줄에 쓰지 않는 것을 권장해요. 명령형 코드와 함수형 코드를 구분하기 위해서예요.
// ✔️ OK
if a then
()
// ❌ Not OK, code formatters will reformat to the above by default
if a then ()
어떤 표현식이라도 여러 줄이라면, 각 조건 분기도 모두 여러 줄이어야 해요.
// ✔️ OK
if cond then
let e1 = something()
e1
else
e2
// ❌ Not OK
if cond then
let e1 = something()
e1
else e2
elif와 else가 있는 여러 조건부는, 한 줄짜리 if/then/else 표현식 규칙을 따르는 경우 if와 같은 스코프로 들여써요.
// ✔️ OK
if cond1 then e1
elif cond2 then e2
elif cond3 then e3
else e4
조건이나 표현식 중 하나라도 여러 줄이면 전체 if/then/else 표현식은 여러 줄이 돼요.
// ✔️ OK
if cond1 then
let e1 = something()
e1
elif cond2 then
e2
elif cond3 then
e3
else
e4
// ❌ Not OK
if cond1 then
let e1 = something()
e1
elif cond2 then e2
elif cond3 then e3
else e4
조건이 여러 줄이거나 한 줄 기준 기본 허용치를 넘는다면, 조건 표현식은 한 번 들여쓰기하고 새 줄을 사용해야 해요. 긴 조건 표현식을 감쌀 때는 if와 then 키워드가 정렬되도록 해요.
// ✔️ OK, but better to refactor, see below
if
complexExpression a b && env.IsDevelopment()
|| someFunctionToCall
aVeryLongParameterNameOne
aVeryLongParameterNameTwo
aVeryLongParameterNameThree
then
e1
else
e2
// ✔️The same applies to nested `elif` or `else if` expressions
if a then
b
elif
someLongFunctionCall
argumentOne
argumentTwo
argumentThree
argumentFour
then
c
else if
someOtherLongFunctionCall
argumentOne
argumentTwo
argumentThree
argumentFour
then
d
하지만 긴 조건은 let 바인딩이나 별도 함수로 리팩터링하는 것이 더 좋은 스타일이에요.
// ✔️ OK
let performAction =
complexExpression a b && env.IsDevelopment()
|| someFunctionToCall
aVeryLongParameterNameOne
aVeryLongParameterNameTwo
aVeryLongParameterNameThree
if performAction then
e1
else
e2
판별 공용체(union) case 표현식 포맷팅
판별 공용체 case를 적용하는 것은 함수나 메서드 적용과 같은 규칙을 따라요. 이름이 대문자로 시작하므로, 코드 포맷터는 튜플 앞의 공백을 제거하죠.
// ✔️ OK
let opt = Some("A", 1)
// OK, but code formatters will remove the space
let opt = Some ("A", 1)
함수 적용처럼, 여러 줄로 나뉘는 구성은 들여쓰기를 사용해요.
// ✔️ OK
let tree1 =
BinaryNode(
BinaryNode (BinaryValue 1, BinaryValue 2),
BinaryNode (BinaryValue 3, BinaryValue 4)
)
리스트·배열 표현식 포맷팅
x :: l은 :: 연산자 주위에 공백을 두고 써요(::는 중위 연산자라서 공백으로 감싸요).
한 줄에 선언하는 리스트와 배열은 여는 대괄호 뒤와 닫는 대괄호 앞에 공백을 둬요.
// ✔️ OK
let xs = [ 1; 2; 3 ]
// ✔️ OK
let ys = [| 1; 2; 3; |]
서로 다른 괄호 계열 연산자 사이에는 항상 최소한 공백 하나를 두세요. 예를 들어 [와 { 사이에는 공백을 남겨요.
// ✔️ OK
[ { Ingredient = "Green beans"; Quantity = 250 }
{ Ingredient = "Pine nuts"; Quantity = 250 }
{ Ingredient = "Feta cheese"; Quantity = 250 }
{ Ingredient = "Olive oil"; Quantity = 10 }
{ Ingredient = "Lemon"; Quantity = 1 } ]
// ❌ Not OK
[{ Ingredient = "Green beans"; Quantity = 250 }
{ Ingredient = "Pine nuts"; Quantity = 250 }
{ Ingredient = "Feta cheese"; Quantity = 250 }
{ Ingredient = "Olive oil"; Quantity = 10 }
{ Ingredient = "Lemon"; Quantity = 1 }]
튜플의 리스트나 배열에도 같은 지침이 적용돼요.
여러 줄로 나뉘는 리스트와 배열은 레코드와 비슷한 규칙을 따라요.
// ✔️ OK
let pascalsTriangle =
[| [| 1 |]
[| 1; 1 |]
[| 1; 2; 1 |]
[| 1; 3; 3; 1 |]
[| 1; 4; 6; 4; 1 |]
[| 1; 5; 10; 10; 5; 1 |]
[| 1; 6; 15; 20; 15; 6; 1 |]
[| 1; 7; 21; 35; 35; 21; 7; 1 |]
[| 1; 8; 28; 56; 70; 56; 28; 8; 1 |] |]
레코드와 마찬가지로 여는 괄호와 닫는 괄호를 각각 자체 줄에 두면, 코드를 옮기거나 함수로 파이프하기가 더 쉬워져요.
// ✔️ OK
let pascalsTriangle =
[|
[| 1 |]
[| 1; 1 |]
[| 1; 2; 1 |]
[| 1; 3; 3; 1 |]
[| 1; 4; 6; 4; 1 |]
[| 1; 5; 10; 10; 5; 1 |]
[| 1; 6; 15; 20; 15; 6; 1 |]
[| 1; 7; 21; 35; 35; 21; 7; 1 |]
[| 1; 8; 28; 56; 70; 56; 28; 8; 1 |]
|]
리스트나 배열 표현식이 바인딩의 오른쪽에 올 때는 Stroustrup 스타일을 쓸 수도 있어요.
// ✔️ OK
let pascalsTriangle = [|
[| 1 |]
[| 1; 1 |]
[| 1; 2; 1 |]
[| 1; 3; 3; 1 |]
[| 1; 4; 6; 4; 1 |]
[| 1; 5; 10; 10; 5; 1 |]
[| 1; 6; 15; 20; 15; 6; 1 |]
[| 1; 7; 21; 35; 35; 21; 7; 1 |]
[| 1; 8; 28; 56; 70; 56; 28; 8; 1 |]
|]
하지만 리스트나 배열 표현식이 바인딩 오른쪽이 아니라, 다른 리스트나 배열 안에 있을 때 — 그 안쪽 표현식이 여러 줄로 나뉘어야 한다면 — 대괄호를 각자 자체 줄에 두어야 해요.
// ✔️ OK - The outer list follows `Stroustrup` style, while the inner lists place their brackets on separate lines
let fn a b = [
[
someReallyLongValueThatWouldForceThisListToSpanMultipleLines
a
]
[
b
someReallyLongValueThatWouldForceThisListToSpanMultipleLines
]
]
// ❌ Not okay
let fn a b = [ [
someReallyLongValueThatWouldForceThisListToSpanMultipleLines
a
]; [
b
someReallyLongValueThatWouldForceThisListToSpanMultipleLines
] ]
배열/리스트 안의 레코드 타입에도 같은 규칙이 적용돼요.
// ✔️ OK - The outer list follows `Stroustrup` style, while the inner lists place their brackets on separate lines
let fn a b = [
{
Foo = someReallyLongValueThatWouldForceThisListToSpanMultipleLines
Bar = a
}
{
Foo = b
Bar = someReallyLongValueThatWouldForceThisListToSpanMultipleLines
}
]
// ❌ Not okay
let fn a b = [ {
Foo = someReallyLongValueThatWouldForceThisListToSpanMultipleLines
Bar = a
}; {
Foo = b
Bar = someReallyLongValueThatWouldForceThisListToSpanMultipleLines
} ]
배열과 리스트를 프로그램으로 생성할 때, 항상 값이 생성되는 경우에는 do ... yield보다 ->를 선호해요.
// ✔️ OK
let squares = [ for x in 1..10 -> x * x ]
// ❌ Not preferred, use "->" when a value is always generated
let squares' = [ for x in 1..10 do yield x * x ]
오래된 F# 버전은 데이터가 조건부로 생성되거나, 평가할 연속 표현식이 있을 수 있는 상황에서 yield를 명시해야 했어요. 어쩔 수 없이 예전 F# 언어 버전으로 컴파일해야 하는 경우가 아니라면 이 yield 키워드를 생략하는 걸 선호해요.
// ✔️ OK
let daysOfWeek includeWeekend =
[
"Monday"
"Tuesday"
"Wednesday"
"Thursday"
"Friday"
if includeWeekend then
"Saturday"
"Sunday"
]
// ❌ Not preferred - omit yield instead
let daysOfWeek' includeWeekend =
[
yield "Monday"
yield "Tuesday"
yield "Wednesday"
yield "Thursday"
yield "Friday"
if includeWeekend then
yield "Saturday"
yield "Sunday"
]
어떤 경우에는 do...yield가 가독성에 도움이 될 수도 있어요. 이런 경우는 주관적이지만, 고려해 볼 만해요.
레코드 표현식 포맷팅
짧은 레코드는 한 줄로 쓸 수 있어요.
// ✔️ OK
let point = { X = 1.0; Y = 0.0 }
더 긴 레코드는 레이블을 새 줄에 써요.
// ✔️ OK
let rainbow =
{ Boss = "Jeffrey"
Lackeys = ["Zippy"; "George"; "Bungle"] }
여러 줄 대괄호 포맷 스타일
여러 줄에 걸치는 레코드에는 흔히 쓰이는 포맷 스타일이 세 가지 있어요: Cramped, Aligned, Stroustrup. Cramped는 F# 코드의 기본 스타일이라, 컴파일러가 코드를 쉽게 파싱하도록 해주는 스타일을 선호하는 경향이 있어요. Aligned와 Stroustrup은 둘 다 멤버를 재정렬하기 쉬워 리팩터링이 편해지는 대신, 특정 상황에서는 코드가 약간 더 장황해질 수 있어요.
Cramped: 역사적 표준이자 기본 F# 레코드 형식이에요. 여는 괄호가 첫 번째 멤버와 같은 줄에, 닫는 괄호가 마지막 멤버와 같은 줄에 와요.
let rainbow =
{ Boss1 = "Jeffrey"
Boss2 = "Jeffrey"
Boss3 = "Jeffrey"
Lackeys = [ "Zippy"; "George"; "Bungle" ] }
Aligned: 각 괄호가 자체 줄을 차지하고, 같은 열에 정렬돼요.
let rainbow =
{
Boss1 = "Jeffrey"
Boss2 = "Jeffrey"
Boss3 = "Jeffrey"
Lackeys = ["Zippy"; "George"; "Bungle"]
}
Stroustrup: 여는 괄호는 바인딩과 같은 줄에, 닫는 괄호는 자체 줄에 와요.
let rainbow = {
Boss1 = "Jeffrey"
Boss2 = "Jeffrey"
Boss3 = "Jeffrey"
Lackeys = [ "Zippy"; "George"; "Bungle" ]
}
이 포맷 스타일 규칙은 리스트와 배열 요소에도 적용돼요.
복사-업데이트(copy-and-update) 레코드 표현식 포맷팅
복사-업데이트 레코드 표현식도 여전히 레코드이므로, 비슷한 지침이 적용돼요.
짧은 표현식은 한 줄에 들어갈 수 있어요.
// ✔️ OK
let point2 = { point with X = 1; Y = 2 }
더 긴 표현식은 새 줄을 사용하고, 위에서 언급한 관례 중 하나에 따라 포맷해요.
// ✔️ OK - Cramped
let newState =
{ state with
Foo =
Some
{ F1 = 0
F2 = "" } }
// ✔️ OK - Aligned
let newState =
{
state with
Foo =
Some
{
F1 = 0
F2 = ""
}
}
// ✔️ OK - Stroustrup
let newState = {
state with
Foo =
Some {
F1 = 0
F2 = ""
}
}
참고: 복사-업데이트 표현식에 Stroustrup 스타일을 쓴다면, 복사해 오는 레코드 이름보다 멤버를 더 깊게 들여써야 해요.
// ✔️ OK
let bilbo = {
hobbit with
Name = "Bilbo"
Age = 111
Region = "The Shire"
}
// ❌ Not OK - Results in compiler error: "Possible incorrect indentation: this token is offside of context started at position"
let bilbo = {
hobbit with
Name = "Bilbo"
Age = 111
Region = "The Shire"
}
패턴 매칭 포맷팅
match의 각 절(clause)에는 들여쓰기 없이 |를 사용해요. 표현식이 짧고 각 하위 표현식도 단순하다면, 한 줄로 쓸 수도 있어요.
// ✔️ OK
match l with
| { him = x; her = "Posh" } :: tail -> x
| _ :: tail -> findDavid tail
| [] -> failwith "Couldn't find David"
// ❌ Not OK, code formatters will reformat to the above by default
match l with
| { him = x; her = "Posh" } :: tail -> x
| _ :: tail -> findDavid tail
| [] -> failwith "Couldn't find David"
패턴 매칭 포맷은 표현식 포맷과 일관되어야 해요. 패턴 인자의 여는 괄호 앞에는 공백을 추가하지 마세요.
// ✔️ OK
match x with
| Some(y) -> y
| None -> 0
// ✔️ OK
match data with
| Success(value) -> value
| Error(msg) -> failwith msg
// ❌ Not OK, pattern formatting should match expression formatting
match x with
| Some (y) -> y
| None -> 0
하지만 표현식에서처럼, 패턴에서도 커리된 인자 사이에는 공백을 사용해요.
// ✔️ OK - space between curried arguments
match x with
| Pattern arg (a, b) -> processValues arg a b
// ❌ Not OK - missing space between curried arguments
match x with
| Pattern arg(a, b) -> processValues arg a b
패턴 매칭 화살표 오른쪽의 표현식이 너무 크다면, match/|에서 한 단계 들여쓴 다음 줄로 옮겨요.
// ✔️ OK
match lam with
| Var v -> 1
| Abs(x, body) ->
1 + sizeLambda body
| App(lam1, lam2) ->
sizeLambda lam1 + sizeLambda lam2
큰 if 조건과 비슷하게, match 표현식이 여러 줄이거나 한 줄 기준 기본 허용치를 넘는다면, match와 with 키워드가 긴 match 표현식을 감싸면서 정렬되도록 해요.
// ✔️ OK, but better to refactor, see below
match
complexExpression a b && env.IsDevelopment()
|| someFunctionToCall
aVeryLongParameterNameOne
aVeryLongParameterNameTwo
aVeryLongParameterNameThree
with
| X y -> y
| _ -> 0
하지만 긴 match 표현식은 let 바인딩이나 별도 함수로 리팩터링하는 것이 더 좋은 스타일이에요.
// ✔️ OK
let performAction =
complexExpression a b && env.IsDevelopment()
|| someFunctionToCall
aVeryLongParameterNameOne
aVeryLongParameterNameTwo
aVeryLongParameterNameThree
match performAction with
| X y -> y
| _ -> 0
패턴 매칭의 화살표를 정렬하는 것은 피해야 해요.
// ✔️ OK
match lam with
| Var v -> v.Length
| Abstraction _ -> 2
// ❌ Not OK, code formatters will reformat to the above by default
match lam with
| Var v -> v.Length
| Abstraction _ -> 2
function 키워드로 시작되는 패턴 매칭은 이전 줄의 시작에서 한 단계 들여쓰기를 해요.
// ✔️ OK
lambdaList
|> List.map (function
| Abs(x, body) -> 1 + sizeLambda 0 body
| App(lam1, lam2) -> sizeLambda (sizeLambda 0 lam1) lam2
| Var v -> 1)
let이나 let rec로 정의된 함수에서 function을 쓰는 것은 일반적으로 match를 쓰는 것보다 피해야 해요. 쓴다면 패턴 규칙이 function 키워드와 정렬되어야 해요.
// ✔️ OK
let rec sizeLambda acc =
function
| Abs(x, body) -> sizeLambda (succ acc) body
| App(lam1, lam2) -> sizeLambda (sizeLambda acc lam1) lam2
| Var v -> succ acc
try/with 표현식 포맷팅
예외 타입에 대한 패턴 매칭은 with와 같은 레벨로 들여써요.
// ✔️ OK
try
if System.DateTime.Now.Second % 3 = 0 then
raise (new System.Exception())
else
raise (new System.ApplicationException())
with
| :? System.ApplicationException ->
printfn "A second that was not a multiple of 3"
| _ ->
printfn "A second that was a multiple of 3"
절이 하나뿐인 경우를 제외하고는 각 절에 |를 붙여요.
// ✔️ OK
try
persistState currentState
with ex ->
printfn "Something went wrong: %A" ex
// ✔️ OK
try
persistState currentState
with :? System.ApplicationException as ex ->
printfn "Something went wrong: %A" ex
// ❌ Not OK, see above for preferred formatting
try
persistState currentState
with
| ex ->
printfn "Something went wrong: %A" ex
// ❌ Not OK, see above for preferred formatting
try
persistState currentState
with
| :? System.ApplicationException as ex ->
printfn "Something went wrong: %A" ex
명명된 인자(named arguments) 포맷팅
명명된 인자는 = 주위에 공백을 둬야 해요.
// ✔️ OK
let makeStreamReader x = new System.IO.StreamReader(path = x)
// ❌ Not OK, spaces are necessary around '=' for named arguments
let makeStreamReader x = new System.IO.StreamReader(path=x)
판별 공용체를 패턴 매칭할 때, 명명된 패턴도 비슷하게 포맷돼요. 예를 들면 다음과 같아요.
type Data =
| TwoParts of part1: string * part2: string
| OnePart of part1: string
// ✔️ OK
let examineData x =
match data with
| OnePartData(part1 = p1) -> p1
| TwoPartData(part1 = p1; part2 = p2) -> p1 + p2
// ❌ Not OK, spaces are necessary around '=' for named pattern access
let examineData x =
match data with
| OnePartData(part1=p1) -> p1
| TwoPartData(part1=p1; part2=p2) -> p1 + p2
변경(mutation) 표현식 포맷팅
변경 표현식 location <- expr은 보통 한 줄로 포맷해요. 여러 줄 포맷이 필요하면, 오른쪽 표현식을 새 줄에 배치해요.
// ✔️ OK
ctx.Response.Headers[HeaderNames.ContentType] <-
Constants.jsonApiMediaType |> StringValues
ctx.Response.Headers[HeaderNames.ContentLength] <-
bytes.Length |> string |> StringValues
// ❌ Not OK, code formatters will reformat to the above by default
ctx.Response.Headers[HeaderNames.ContentType] <- Constants.jsonApiMediaType
|> StringValues
ctx.Response.Headers[HeaderNames.ContentLength] <- bytes.Length
|> string
|> StringValues
객체 표현식 포맷팅
객체 표현식의 멤버는 정렬하고, 멤버는 한 단계 들여써요.
// ✔️ OK
let comparer =
{ new IComparer<string> with
member x.Compare(s1, s2) =
let rev (s: String) = new String (Array.rev (s.ToCharArray()))
let reversed = rev s1
reversed.CompareTo (rev s2) }
Stroustrup 스타일을 쓰는 것도 좋아요.
let comparer = {
new IComparer<string> with
member x.Compare(s1, s2) =
let rev (s: String) = new String(Array.rev (s.ToCharArray()))
let reversed = rev s1
reversed.CompareTo(rev s2)
}
빈 타입 정의는 한 줄로 포맷할 수 있어요.
type AnEmptyType = class end
선택한 페이지 폭과 무관하게 = class end는 항상 같은 줄에 있어야 해요.
인덱스/슬라이스 표현식 포맷팅
인덱스 표현식은 여는 괄호와 닫는 괄호 주위에 공백을 두지 않아요.
// ✔️ OK
let v = expr[idx]
let y = myList[0..1]
// ❌ Not OK
let v = expr[ idx ]
let y = myList[ 0 .. 1 ]
이 규칙은 예전 expr.[idx] 문법에도 적용돼요.
// ✔️ OK
let v = expr.[idx]
let y = myList.[0..1]
// ❌ Not OK
let v = expr.[ idx ]
let y = myList.[ 0 .. 1 ]
인용(quoted) 표현식 포맷팅
인용된 표현식이 여러 줄이라면, 구분 기호(<@, @>, <@@, @@>)를 각자 다른 줄에 배치해요.
// ✔️ OK
<@
let f x = x + 10
f 20
@>
// ❌ Not OK
<@ let f x = x + 10
f 20
@>
한 줄 표현식에서는 구분 기호를 표현식과 같은 줄에 배치해요.
// ✔️ OK
<@ 1 + 1 @>
// ❌ Not OK
<@
1 + 1
@>
체인(chained) 표현식 포맷팅
체인된 표현식(함수 적용이 .와 얽힌 것)이 길어지면, 각 적용 호출을 다음 줄에 배치해요. 체인의 후속 링크는 선두 링크 뒤에서 한 단계 들여써요.
// ✔️ OK
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(fun webBuilder -> webBuilder.UseStartup<Startup>())
// ✔️ OK
Cli
.Wrap("git")
.WithArguments(arguments)
.WithWorkingDirectory(__SOURCE_DIRECTORY__)
.ExecuteBufferedAsync()
.Task
선두 링크는 단순한 식별자라면 여러 링크로 구성될 수 있어요. 예를 들어 완전히 정규화된 네임스페이스를 추가하는 경우예요.
// ✔️ OK
Microsoft.Extensions.Hosting.Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(fun webBuilder -> webBuilder.UseStartup<Startup>())
후속 링크도 단순한 식별자여야 해요.
// ✔️ OK
configuration.MinimumLevel
.Debug()
// Notice how `.WriteTo` does not need its own line.
.WriteTo.Logger(fun loggerConfiguration ->
loggerConfiguration.Enrich
.WithProperty("host", Environment.MachineName)
.Enrich.WithProperty("user", Environment.UserName)
.Enrich.WithProperty("application", context.HostingEnvironment.ApplicationName))
함수 적용 안의 인자가 남은 줄에 맞지 않으면, 각 인자를 다음 줄에 배치해요.
// ✔️ OK
WebHostBuilder()
.UseKestrel()
.UseUrls("http://*:5000/")
.UseCustomCode(
longArgumentOne,
longArgumentTwo,
longArgumentThree,
longArgumentFour
)
.UseContentRoot(Directory.GetCurrentDirectory())
.UseStartup<Startup>()
.Build()
// ✔️ OK
Cache.providedTypes
.GetOrAdd(cacheKey, addCache)
.Value
// ❌ Not OK, formatting tools will reformat to the above
Cache
.providedTypes
.GetOrAdd(
cacheKey,
addCache
)
.Value
함수 적용 안의 람다 인자는 여는 (과 같은 줄에서 시작해야 해요.
// ✔️ OK
builder
.WithEnvironment()
.WithLogger(fun loggerConfiguration ->
// ...
())
// ❌ Not OK, formatting tools will reformat to the above
builder
.WithEnvironment()
.WithLogger(
fun loggerConfiguration ->
// ...
())
선언 포맷팅
이 절에서는 여러 종류의 선언을 어떻게 포맷하는지 다뤄요.
선언 사이에 빈 줄을 넣어요
최상위 함수와 클래스 정의는 빈 줄 하나로 구분해요. 예를 들면 다음과 같아요.
// ✔️ OK
let thing1 = 1+1
let thing2 = 1+2
let thing3 = 1+3
type ThisThat = This | That
// ❌ Not OK
let thing1 = 1+1
let thing2 = 1+2
let thing3 = 1+3
type ThisThat = This | That
구성(construct)에 XML 문서 주석이 있으면, 주석 앞에 빈 줄을 넣어요.
// ✔️ OK
/// This is a function
let thisFunction() =
1 + 1
/// This is another function, note the blank line before this line
let thisFunction() =
1 + 1
let과 member 선언 포맷팅
let과 member 선언을 포맷할 때는, 보통 바인딩의 오른쪽이 한 줄에 가거나(너무 길면), 새 줄에 한 단계 들여써서 가요.
예를 들어 다음 예시들은 규칙을 잘 지킨 형태예요.
// ✔️ OK
let a =
"""
foobar, long string
"""
// ✔️ OK
type File =
member this.SaveAsync(path: string) : Async<unit> =
async {
// IO operation
return ()
}
// ✔️ OK
let c =
{ Name = "Bilbo"
Age = 111
Region = "The Shire" }
// ✔️ OK
let d =
while f do
printfn "%A" x
다음은 규칙을 어긴 형태예요.
// ❌ Not OK, code formatters will reformat to the above by default
let a = """
foobar, long string
"""
let d = while f do
printfn "%A" x
레코드 타입 인스턴스화는 대괄호를 자체 줄에 배치할 수도 있어요.
// ✔️ OK
let bilbo =
{
Name = "Bilbo"
Age = 111
Region = "The Shire"
}
여는 {를 바인딩 이름과 같은 줄에 두는 Stroustrup 스타일을 써도 좋아요.
// ✔️ OK
let bilbo = {
Name = "Bilbo"
Age = 111
Region = "The Shire"
}
멤버는 빈 줄 하나로 구분하고, 문서화 주석을 달아요.
// ✔️ OK
/// This is a thing
type ThisThing(value: int) =
/// Gets the value
member _.Value = value
/// Returns twice the value
member _.TwiceValue() = value*2
관련된 함수들의 그룹을 구분하려고 추가 빈 줄을 (드물게) 사용해도 돼요. 서로 관련된 한 줄짜리들(예를 들어 더미 구현 세트) 사이에는 빈 줄을 생략할 수 있어요. 함수 안에서는 논리적 구획을 나타내려고 빈 줄을 드물게 사용해요.
함수·멤버 인자 포맷팅
함수를 정의할 때는 각 인자 주위에 공백을 둬요.
// ✔️ OK
let myFun (a: decimal) (b: int) c = a + b + c
// ❌ Not OK, code formatters will reformat to the above by default
let myFunBad (a:decimal)(b:int)c = a + b + c
긴 함수 정의를 갖고 있다면, 매개변수를 새 줄에 배치하고 다음 매개변수의 들여쓰기 수준과 맞춰 들여써요.
// ✔️ OK
module M =
let longFunctionWithLotsOfParameters
(aVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse)
=
// ... the body of the method follows
let longFunctionWithLotsOfParametersAndReturnType
(aVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse)
: ReturnType =
// ... the body of the method follows
let longFunctionWithLongTupleParameter
(
aVeryLongParam: AVeryLongTypeThatYouNeedToUse,
aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse,
aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse
) =
// ... the body of the method follows
let longFunctionWithLongTupleParameterAndReturnType
(
aVeryLongParam: AVeryLongTypeThatYouNeedToUse,
aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse,
aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse
) : ReturnType =
// ... the body of the method follows
이 규칙은 멤버, 생성자, 튜플을 쓰는 매개변수에도 적용돼요.
// ✔️ OK
type TypeWithLongMethod() =
member _.LongMethodWithLotsOfParameters
(
aVeryLongParam: AVeryLongTypeThatYouNeedToUse,
aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse,
aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse
) =
// ... the body of the method
// ✔️ OK
type TypeWithLongConstructor
(
aVeryLongCtorParam: AVeryLongTypeThatYouNeedToUse,
aSecondVeryLongCtorParam: AVeryLongTypeThatYouNeedToUse,
aThirdVeryLongCtorParam: AVeryLongTypeThatYouNeedToUse
) =
// ... the body of the class follows
// ✔️ OK
type TypeWithLongSecondaryConstructor () =
new
(
aVeryLongCtorParam: AVeryLongTypeThatYouNeedToUse,
aSecondVeryLongCtorParam: AVeryLongTypeThatYouNeedToUse,
aThirdVeryLongCtorParam: AVeryLongTypeThatYouNeedToUse
) =
// ... the body of the constructor follows
매개변수가 커리(curried)되어 있다면, = 문자와 함께 반환 타입(있는 경우)을 새 줄에 배치해요.
// ✔️ OK
type TypeWithLongCurriedMethods() =
member _.LongMethodWithLotsOfCurriedParamsAndReturnType
(aVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse)
: ReturnType =
// ... the body of the method
member _.LongMethodWithLotsOfCurriedParams
(aVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aSecondVeryLongParam: AVeryLongTypeThatYouNeedToUse)
(aThirdVeryLongParam: AVeryLongTypeThatYouNeedToUse)
=
// ... the body of the method
이렇게 하면 너무 긴 줄(반환 타입 이름이 길어질 수 있는 경우)을 피하고, 매개변수를 추가할 때 줄이 깨지는 정도를 줄일 수 있어요.
연산자 선언 포맷팅
선택적으로 연산자 정의 주위에 공백을 둘 수 있어요.
// ✔️ OK
let ( !> ) x f = f x
// ✔️ OK
let (!>) x f = f x
*로 시작하면서 문자가 둘 이상인 사용자 정의 연산자는, 컴파일러의 모호성을 피하기 위해 정의 시작 부분에 공백을 추가해야 해요. 그러니 모든 연산자의 정의를 공백 문자 하나로 감싸는 것을 권장해요.
레코드 선언 포맷팅
레코드 선언에서는 기본적으로 타입 정의의 {를 네 칸 들여쓰고, 레이블 목록을 같은 줄에서 시작하며, 멤버(있으면)를 { 토큰과 정렬해요.
// ✔️ OK
type PostalAddress =
{ Address: string
City: string
Zip: string }
대괄호를 자체 줄에 두고, 레이블을 네 칸 더 들여쓰는 방식도 흔해요.
// ✔️ OK
type PostalAddress =
{
Address: string
City: string
Zip: string
}
타입 정의의 첫 줄 끝에 {를 둘 수도 있어요(Stroustrup 스타일).
// ✔️ OK
type PostalAddress = {
Address: string
City: string
Zip: string
}
추가 멤버가 필요하다면, 가능하면 with/end를 쓰지 마세요.
// ✔️ OK
type PostalAddress =
{ Address: string
City: string
Zip: string }
member x.ZipAndCity = $"{x.Zip} {x.City}"
// ❌ Not OK, code formatters will reformat to the above by default
type PostalAddress =
{ Address: string
City: string
Zip: string }
with
member x.ZipAndCity = $"{x.Zip} {x.City}"
end
// ✔️ OK
type PostalAddress =
{
Address: string
City: string
Zip: string
}
member x.ZipAndCity = $"{x.Zip} {x.City}"
// ❌ Not OK, code formatters will reformat to the above by default
type PostalAddress =
{
Address: string
City: string
Zip: string
}
with
member x.ZipAndCity = $"{x.Zip} {x.City}"
end
이 스타일 규칙의 예외는 레코드를 Stroustrup 스타일로 포맷하는 경우예요. 이 상황에서는 컴파일러 규칙 때문에, 인터페이스를 구현하거나 추가 멤버를 붙이려면 with 키워드가 필요해요.
// ✔️ OK
type PostalAddress = {
Address: string
City: string
Zip: string
} with
member x.ZipAndCity = $"{x.Zip} {x.City}"
// ❌ Not OK, this is currently invalid F# code
type PostalAddress = {
Address: string
City: string
Zip: string
}
member x.ZipAndCity = $"{x.Zip} {x.City}"
레코드 필드에 XML 문서를 추가할 때는 Aligned나 Stroustrup 스타일을 선호하고, 멤버 사이에 추가 공백을 넣어요.
// ❌ Not OK - putting { and comments on the same line should be avoided
type PostalAddress =
{ /// The address
Address: string
/// The city
City: string
/// The zip code
Zip: string }
/// Format the zip code and the city
member x.ZipAndCity = $"{x.Zip} {x.City}"
// ✔️ OK
type PostalAddress =
{
/// The address
Address: string
/// The city
City: string
/// The zip code
Zip: string
}
/// Format the zip code and the city
member x.ZipAndCity = $"{x.Zip} {x.City}"
// ✔️ OK - Stroustrup Style
type PostalAddress = {
/// The address
Address: string
/// The city
City: string
/// The zip code
Zip: string
} with
/// Format the zip code and the city
member x.ZipAndCity = $"{x.Zip} {x.City}"
레코드에 인터페이스 구현이나 멤버를 선언할 때는, 여는 토큰과 닫는 토큰을 각각 새 줄에 배치하는 것이 더 나아요.
// ✔️ OK
// Declaring additional members on PostalAddress
type PostalAddress =
{
/// The address
Address: string
/// The city
City: string
/// The zip code
Zip: string
}
member x.ZipAndCity = $"{x.Zip} {x.City}"
// ✔️ OK
type MyRecord =
{
/// The record field
SomeField: int
}
interface IMyInterface
이 규칙들은 익명 레코드 타입 별칭에도 동일하게 적용돼요.
판별 공용체 선언 포맷팅
판별 공용체 선언에서는 타입 정의의 |를 네 칸 들여써요.
// ✔️ OK
type Volume =
| Liter of float
| FluidOunce of float
| ImperialPint of float
// ❌ Not OK
type Volume =
| Liter of float
| USPint of float
| ImperialPint of float
단일이고 짧은 공용체일 때는 선두 |를 생략할 수 있어요.
// ✔️ OK
type Address = Address of string
더 길거나 여러 줄짜리 공용체라면 |를 유지하고, 각 공용체 필드를 새 줄에 배치하며, 각 줄 끝에 구분 *를 둬요.
// ✔️ OK
[<NoEquality; NoComparison>]
type SynBinding =
| SynBinding of
accessibility: SynAccess option *
kind: SynBindingKind *
mustInline: bool *
isMutable: bool *
attributes: SynAttributes *
xmlDoc: PreXmlDoc *
valData: SynValData *
headPat: SynPat *
returnInfo: SynBindingReturnInfo option *
expr: SynExpr *
range: range *
seqPoint: DebugPointAtBinding
문서 주석을 추가할 때는 각 /// 주석 앞에 빈 줄을 사용해요.
// ✔️ OK
/// The volume
type Volume =
/// The volume in liters
| Liter of float
/// The volume in fluid ounces
| FluidOunce of float
/// The volume in imperial pints
| ImperialPint of float
리터럴 선언 포맷팅
Literal 특성을 쓰는 F# 리터럴은 특성을 자체 줄에 배치하고 PascalCase로 이름을 지어요.
// ✔️ OK
[<Literal>]
let Path = __SOURCE_DIRECTORY__ + "/" + __SOURCE_FILE__
[<Literal>]
let MyUrl = "www.mywebsitethatiamworkingwith.com"
특성을 값과 같은 줄에 두는 것은 피하세요.
모듈 선언 포맷팅
로컬 모듈의 코드는 모듈에 상대적으로 들여써야 하지만, 최상위 모듈의 코드는 들여쓰면 안 돼요. 네임스페이스 요소는 들여쓸 필요가 없어요.
// ✔️ OK - A is a top-level module.
module A
let function1 a b = a - b * b
// ✔️ OK - A1 and A2 are local modules.
module A1 =
let function1 a b = a * a + b * b
module A2 =
let function2 a b = a * a - b * b
do 선언 포맷팅
타입 선언, 모듈 선언, 계산 표현식에서 부수 효과(side-effecting) 연산을 위해 do나 do!를 써야 할 때가 있어요. 이것들이 여러 줄로 나뉘면, let/let!과 들여쓰기를 일관되게 하도록 들여쓰기와 새 줄을 사용해요. 클래스에서 do를 쓰는 예시예요.
// ✔️ OK
type Foo() =
let foo =
fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
do
fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
// ❌ Not OK - notice the "do" expression is indented one space less than the `let` expression
type Foo() =
let foo =
fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
do fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
다음은 두 칸 들여쓰기를 쓰는 do!의 예시예요(do!에서는 우연히 네 칸 들여쓰기를 할 때 두 방식 사이에 차이가 없기 때문이에요).
// ✔️ OK
async {
let! foo =
fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
do!
fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
}
// ❌ Not OK - notice the "do!" expression is indented two spaces more than the `let!` expression
async {
let! foo =
fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
do! fooBarBaz
|> loremIpsumDolorSitAmet
|> theQuickBrownFoxJumpedOverTheLazyDog
}
계산 표현식 연산 포맷팅
계산 표현식의 사용자 지정 연산(custom operations)을 만들 때는 camelCase 이름을 사용하는 것을 권장해요.
// ✔️ OK
type MathBuilder() =
member _.Yield _ = 0
[<CustomOperation("addOne")>]
member _.AddOne (state: int) =
state + 1
[<CustomOperation("subtractOne")>]
member _.SubtractOne (state: int) =
state - 1
[<CustomOperation("divideBy")>]
member _.DivideBy (state: int, divisor: int) =
state / divisor
[<CustomOperation("multiplyBy")>]
member _.MultiplyBy (state: int, factor: int) =
state * factor
let math = MathBuilder()
let myNumber =
math {
addOne
addOne
addOne
subtractOne
divideBy 2
multiplyBy 10
}
궁극적으로는 모델링하는 도메인이 네이밍 관례를 정해야 해요. 다른 관례가 관용적이라면 그 관례를 쓰는 게 맞아요.
표현식의 반환값이 계산 표현식이라면, 계산 표현식 키워드 이름을 자체 줄에 두는 것을 선호해요.
// ✔️ OK
let foo () =
async {
let! value = getValue()
do! somethingElse()
return! anotherOperation value
}
계산 표현식을 바인딩 이름과 같은 줄에 두는 것도 선호할 수 있어요.
// ✔️ OK
let foo () = async {
let! value = getValue()
do! somethingElse()
return! anotherOperation value
}
어느 쪽이든, 코드베이스 전체에서 일관되게 유지하는 것이 목표예요. 포맷터가 이 선호도를 지정해 일관성을 유지하게 해줄 수도 있어요.
타입·타입 주석 포맷팅
이 절에서는 타입과 타입 주석을 포맷하는 법을 다뤄요. 여기에는 .fsi 확장자의 시그니처 파일 포맷도 포함돼요.
제네릭은 접두(prefix) 문법을 선호해요 (Foo<T>), 몇 가지 예외가 있어요
F#은 제네릭 타입을 쓰는 방식에 후치(postfix) 스타일(예: int list)과 접두 스타일(예: list<int>)을 모두 허용해요. 후치 스타일은 타입 인자가 하나일 때만 쓸 수 있어요. 여섯 가지 특정 타입을 제외하고는 항상 .NET 스타일(접두) 을 선호해요.
- F# 리스트는 후치 형태를 써요:
list<int>보다int list. - F# 옵션(Option) 은 후치 형태를 써요:
option<int>보다int option. - F# 값 옵션(Value Option) 은 후치 형태를 써요:
voption<int>보다int voption. - F# 배열은 후치 형태를 써요:
array<int>나int[]보다int array. - 참조 셀(Reference Cells) 은
ref<int>나Ref<int>보다int ref를 써요. - F# 시퀀스는 후치 형태를 써요:
seq<int>보다int seq. - 다른 모든 타입은 접두 형태를 써요.
함수 타입 포맷팅
함수의 시그니처를 정의할 때는 -> 기호 주위에 공백을 둬요.
// ✔️ OK
type MyFun = int -> int -> string
// ❌ Not OK
type MyFunBad = int->int->string
값·인자 타입 주석 포맷팅
값이나 인자를 타입 주석과 함께 정의할 때는 : 기호 뒤에는 공백을 두고, 앞에는 두지 않아요.
// ✔️ OK
let complexFunction (a: int) (b: int) c = a + b + c
let simpleValue: int = 0 // Type annotation for let-bound value
type C() =
member _.Property: int = 1
// ❌ Not OK
let complexFunctionPoorlyAnnotated (a :int) (b :int) (c:int) = a + b + c
let simpleValuePoorlyAnnotated1:int = 1
let simpleValuePoorlyAnnotated2 :int = 2
여러 줄 타입 주석 포맷팅
타입 주석이 길거나 여러 줄이면, 새 줄에 한 단계 들여써서 배치해요.
type ExprFolder<'State> =
{ exprIntercept:
('State -> Expr -> 'State) -> ('State -> Expr -> 'State -> 'State -> Exp -> 'State }
let UpdateUI
(model:
#if NETCOREAPP2_1
ITreeModel
#else
TreeModel
#endif
)
(info: FileInfo) =
// code
()
let f
(x:
{|
a: Second
b: Metre
c: Kilogram
d: Ampere
e: Kelvin
f: Mole
g: Candela
|})
=
x.a
type Sample
(
input:
LongTupleItemTypeOneThing *
LongTupleItemTypeThingTwo *
LongTupleItemTypeThree *
LongThingFour *
LongThingFiveYow
) =
class
end
인라인 익명 레코드 타입에는 Stroustrup 스타일을 쓸 수도 있어요.
let f
(x: {|
x: int
y: AReallyLongTypeThatIsMuchLongerThan40Characters
|})
=
x
반환 타입 주석 포맷팅
함수나 멤버의 반환 타입 주석에서는 : 기호 앞과 뒤에 모두 공백을 둬요.
// ✔️ OK
let myFun (a: decimal) b c : decimal = a + b + c
type C() =
member _.SomeMethod(x: int) : int = 1
// ❌ Not OK
let myFunBad (a: decimal) b c:decimal = a + b + c
let anotherFunBad (arg: int): unit = ()
type C() =
member _.SomeMethodBad(x: int): int = 1
시그니처의 타입 포맷팅
시그니처에서 전체 함수 타입을 쓸 때는 인자를 여러 줄로 나눠야 할 때가 있어요. 반환 타입은 항상 들여써요.
튜플 함수의 경우, 인자는 *로 구분하고 각 줄 끝에 배치해요.
예를 들어 다음 구현을 가진 함수를 생각해 볼게요.
let SampleTupledFunction(arg1, arg2, arg3, arg4) = ...
대응하는 시그니처 파일(.fsi 확장자)에서는, 여러 줄 포맷이 필요할 때 함수를 다음과 같이 포맷할 수 있어요.
// ✔️ OK
val SampleTupledFunction:
arg1: string *
arg2: string *
arg3: int *
arg4: int ->
int list
마찬가지로 커리 함수를 생각해 볼게요.
let SampleCurriedFunction arg1 arg2 arg3 arg4 = ...
대응하는 시그니처 파일에서는 ->를 각 줄 끝에 배치해요.
// ✔️ OK
val SampleCurriedFunction:
arg1: string ->
arg2: string ->
arg3: int ->
arg4: int ->
int list
마찬가지로 커리 인자와 튜플 인자를 섞어 쓰는 함수를 생각해 볼게요.
// Typical call syntax:
let SampleMixedFunction
(arg1, arg2)
(arg3, arg4, arg5)
(arg6, arg7)
(arg8, arg9, arg10) = ..
대응하는 시그니처 파일에서는, 튜플이 앞에 오는 타입들이 들여써져요.
// ✔️ OK
val SampleMixedFunction:
arg1: string *
arg2: string ->
arg3: string *
arg4: string *
arg5: TType ->
arg6: TType *
arg7: TType ->
arg8: TType *
arg9: TType *
arg10: TType ->
TType list
타입 시그니처의 멤버에도 같은 규칙이 적용돼요.
type SampleTypeName =
member ResolveDependencies:
arg1: string *
arg2: string ->
string
명시적 제네릭 타입 인자·제약 포맷팅
아래 지침은 함수 정의, 멤버 정의, 타입 정의, 함수 적용에 모두 적용돼요.
너무 길지 않다면 제네릭 타입 인자와 제약을 한 줄에 유지해요.
// ✔️ OK
let f<'T1, 'T2 when 'T1: equality and 'T2: comparison> param =
// function body
제네릭 타입 인자/제약과 함수 매개변수가 둘 다 안 맞지만, 타입 매개변수/제약만은 맞는다면, 매개변수를 새 줄에 배치해요.
// ✔️ OK
let f<'T1, 'T2 when 'T1: equality and 'T2: comparison>
param
=
// function body
타입 매개변수나 제약이 너무 길면, 아래처럼 끊고 정렬해요. 타입 매개변수 목록은 길이와 무관하게 함수와 같은 줄에 유지해요. 제약은 when을 첫 줄에 두고, 각 제약을 길이와 무관하게 한 줄에 유지해요. >는 마지막 줄 끝에 배치해요. 제약은 한 단계 들여써요.
// ✔️ OK
let inline f< ^T1, ^T2
when ^T1: (static member Foo1: unit -> ^T2)
and ^T2: (member Foo2: unit -> int)
and ^T2: (member Foo3: string -> ^T1 option)>
arg1
arg2
=
// function body
타입 매개변수/제약이 끊어졌지만 일반 함수 매개변수는 없다면, 무조건 =를 새 줄에 배치해요.
// ✔️ OK
let inline f< ^T1, ^T2
when ^T1: (static member Foo1: unit -> ^T2)
and ^T2: (member Foo2: unit -> int)
and ^T2: (member Foo3: string -> ^T1 option)>
=
// function body
함수 적용에도 같은 규칙이 적용돼요.
// ✔️ OK
myObj
|> Json.serialize<
{| child: {| displayName: string; kind: string |}
newParent: {| id: string; displayName: string |}
requiresApproval: bool |}>
// ✔️ OK
Json.serialize<
{| child: {| displayName: string; kind: string |}
newParent: {| id: string; displayName: string |}
requiresApproval: bool |}>
myObj
상속(inheritance) 포맷팅
기본 클래스 생성자의 인자들은 inherit 절의 인자 목록에 나타나요. inherit 절을 새 줄에, 한 단계 들여써서 배치해요.
type MyClassBase(x: int) =
class
end
// ✔️ OK
type MyClassDerived(y: int) =
inherit MyClassBase(y * 2)
// ❌ Not OK
type MyClassDerived(y: int) = inherit MyClassBase(y * 2)
생성자가 길거나 여러 줄이면, 인자들을 새 줄에, 한 단계 들여써서 배치해요.
이 여러 줄 생성자는 여러 줄 함수 적용의 규칙에 따라 포맷해요.
type MyClassBase(x: string) =
class
end
// ✔️ OK
type MyClassDerived(y: string) =
inherit
MyClassBase(
"""
very long
string example
"""
)
// ❌ Not OK
type MyClassDerived(y: string) =
inherit MyClassBase(
"""
very long
string example
""")
기본 생성자 포맷팅
기본 포맷팅 관례에서는 기본 생성자의 타입 이름과 괄호 사이에 공백을 넣지 않아요.
// ✔️ OK
type MyClass() =
class
end
type MyClassWithParams(x: int, y: int) =
class
end
// ❌ Not OK
type MyClass () =
class
end
type MyClassWithParams (x: int, y: int) =
class
end
여러 생성자
inherit 절이 레코드의 일부일 때, 짧다면 같은 줄에, 길거나 여러 줄이라면 새 줄에 한 단계 들여써서 배치해요.
type BaseClass =
val string1: string
new () = { string1 = "" }
new (str) = { string1 = str }
type DerivedClass =
inherit BaseClass
val string2: string
new (str1, str2) = { inherit BaseClass(str1); string2 = str2 }
new () =
{ inherit
BaseClass(
"""
very long
string example
"""
)
string2 = str2 }
특성(attributes) 포맷팅
특성은 구성(construct) 위에 배치해요.
// ✔️ OK
[<SomeAttribute>]
type MyClass() = ...
// ✔️ OK
[<RequireQualifiedAccess>]
module M =
let f x = x
// ✔️ OK
[<Struct>]
type MyRecord =
{ Label1: int
Label2: string }
특성은 XML 문서 뒤에 와야 해요.
// ✔️ OK
/// Module with some things in it.
[<RequireQualifiedAccess>]
module M =
let f x = x
매개변수의 특성 포맷팅
특성은 매개변수에도 붙일 수 있어요. 이 경우 특성을 매개변수와 같은 줄에, 이름 앞에 배치해요.
// ✔️ OK - defines a class that takes an optional value as input defaulting to false.
type C() =
member _.M([<Optional; DefaultParameterValue(false)>] doSomething: bool)
여러 특성 포맷팅
매개변수가 아닌 구성에 여러 특성을 적용할 때는, 각 특성을 별도의 줄에 배치해요.
// ✔️ OK
[<Struct>]
[<IsByRefLike>]
type MyRecord =
{ Label1: int
Label2: string }
매개변수에 적용할 때는 특성을 같은 줄에 배치하고 ; 구분자로 나눠요.
감사의 말
이 지침은 Anh-Dung Phan의 "A comprehensive guide to F# Formatting Conventions"를 바탕으로 해요.
더 알아보기
- F# 코딩 규칙(Coding conventions) — 네이밍 규칙도 다루는 문서예요.
- 컴포넌트 디자인 지침(Component design guidelines) — 네이밍 규칙도 다루는 문서예요.
- Fantomas 코드 포맷터 — F# 커뮤니티 표준 자동 포맷팅 도구.