이번 글에서는 C# 프로그램을 단계별로 F# 프로그램으로 변환하면서 두 언어간 차이에 대해 알아보기로 한다. 기본 C# 프로그램은 지난번 글에서 만든 로또 번호 생성기를 이용한 것으로, 전체 소스는 다음과 같다:

using System;
using System.Collections.Generic;

namespace CSharpToFSharp {
    static class Program {
        static List<int> LottoNumbers(int minValue, int maxValue, int count) {
            var rand = new Random((int)DateTime.Now.Ticks);
            var numbers = new List<int>();
            while (numbers.Count != count) {
                var num = rand.Next(minValue, maxValue + 1);
                if (!numbers.Contains(num)) {
                    numbers.Add(num);
                }
            }
            return numbers;
        }

        static void Main(string[] args) {
            List<int> numbers = LottoNumbers(1, 45, 6);
            Console.WriteLine(string.Join(", ", numbers));
        }
    }
}

전체 단계는 다음과 같다:

  • namespace 블록을 선언문으로 대체
  • 블록 구분자 {}와 문장 끝 ; 삭제
  • static classmodule로 대체
  • let 키워드로 함수와 변수 정의
  • 타입 지정 형식 변경
  • 타입 지정 생략
  • 함수 파라미터 형식 변경
  • 제어문과 연산자 대체
  • newreturn 키워드 제거
  • C# 타입 캐스팅을 F# 함수로 대체
  • [<EntryPoint>] 어트리뷰트의 사용

쓰고 보니 11단계나 되는데 생각보다 매우 간단하므로 10분안에 이해할 수 있을 것이다.

namespace 블록을 선언문으로 대체

C#에서는 네임스페이스가 블록인데 비해 F#에서는 자바의 패키지 선언과 마찬가지로 한줄 짜리 문장이다. 그리고 using문은 반드시 네임스페이스 내부에 있어야 하고 키워드로는 open을 쓴다.

namespace CSharpToFSharp;

open System;
open System.Collections.Generic;

static class Program {
    static List<int> LottoNumbers(int minValue, int maxValue, int count) {
        var rand = new Random((int)DateTime.Now.Ticks);
        var numbers = new List<int>();
        while (numbers.Count != count) {
            var num = rand.Next(minValue, maxValue + 1);
            if (!numbers.Contains(num)) {
                numbers.Add(num);
            }
        }
        return numbers;
    }

    static void Main(string[] args) {
        List<int> numbers = LottoNumbers(1, 45, 6);
        Console.WriteLine(string.Join(", ", numbers));
    }
}

바꾸고 보니 들여쓰기 깊이가 한단계 낮아져 보기가 한결 수월해진 느낌이다. C#도 원래부터 이렇게 만들었으면 좋을 뻔 했다.

블록 구분자 {}와 문장 끝 ; 삭제

F#이 C#과 다르게 보이는 가장 근본적인 차이가 블록 들여쓰기 방식이다. F#에서는 C 계열 언어에서처럼 {}를 써서 블록을 구분하지 않고 파이썬처럼 공백을 이용한 들여쓰기로 블록을 구분한다. 그리고 문장 끝에 ;를 붙이지 않아도 된다(한 줄에 두 개 이상의 문장을 쓸 때만 문장 사이에 쓴다). 또한 변수 정의와 같이 모듈과 함수를 정의할 때도 이름 뒤에 =를 붙인다.

namespace CSharpToFSharp

open System
open System.Collections.Generic

static class Program =
    static List<int> LottoNumbers(int minValue, int maxValue, int count) =
        var rand = new Random((int)DateTime.Now.Ticks)
        var numbers = new List<int>()
        while (numbers.Count != count)
            var num = rand.Next(minValue, maxValue + 1)
            if (!numbers.Contains(num))
                numbers.Add(num)
        return numbers

    static void Main(string[] args) =
        List<int> numbers = LottoNumbers(1, 45, 6)
        Console.WriteLine(string.Join(", ", numbers))

단지 {}와 `;’를 삭제했을 뿐인데 여기서부터는 C#보다 F# 쪽에 가까워져 보인다.

참고로 F#에서는 들여쓰기할 때 탭 문자 사용이 금지되어 있어서 모든 공백은 스페이스 문자로만 해야 한다. 이러한 강제 규정 때문에 남이 짠 소스를 내 컴퓨터로 가져왔을 때 원래 것과 다르게 보이거나 들여쓰기가 엉망이 되는 현상(일명 탭 지옥)이 절대 일어나지 않는다.

static classmodule로 대체

F#에서 기본적으로 모든 함수는 모듈 내에 두게 되어 있다. 이 모듈은 C#의 정적 클래스와 의미상 같은 것으로 실제로도 정적 클래스로 컴파일된다. 그리고 함수 이름 앞에 static 키워드를 붙이지 않는다.

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    List<int> LottoNumbers(int minValue, int maxValue, int count) =
        var rand = new Random((int)DateTime.Now.Ticks)
        var numbers = new List<int>()
        while (numbers.Count != count)
            var num = rand.Next(minValue, maxValue + 1)
            if (!numbers.Contains(num))
                numbers.Add(num)
        return numbers

    void Main(string[] args) =
        List<int> numbers = LottoNumbers(1, 45, 6)
        Console.WriteLine(string.Join(", ", numbers))

let 키워드로 함수와 변수 정의

변수와 함수를 정의할 때는 공통적으로 이름 앞에 let 키워드를 쓴다. 그에 따라 변수와 함수의 정의 형태가 아주 유사해지는데, 기본적으로는 이름 뒤에 파라미터가 더 있으면 함수고, 이름 뒤에 아무것도 없으면 변수다(더 자세한 구분은 다음에). 그리고 관례상 함수와 변수의 이름은 소문자로 시작하는 카멜케이스 방식을 따른다(자바와 동일하다). 앞의 소스에 반영하면 다음과 같이 된다:

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let List<int> lottoNumbers(int minValue, int maxValue, int count) =
        let rand = new Random((int)DateTime.Now.Ticks)
        let numbers = new List<int>()
        while (numbers.Count != count)
            let num = rand.Next(minValue, maxValue + 1)
            if (!numbers.Contains(num))
                numbers.Add(num)
        return numbers

    let void main(string[] args) =
        let List<int> numbers = lottoNumbers(1, 45, 6)
        Console.WriteLine(string.Join(", ", numbers))

여기서 lottoNumbersmain은 뒤에 파라미터가 있으므로 함수고, rand, numbers, num 등은 변수다.

타입 지정 형식 변경

타입 지정을 변수 이름 앞에 두는 C 계열 언어와 달리 F#에서는 이름 뒤에 :과 함께 표기한다. 함수의 리턴 타입은 파라미터 리스트 뒤에 :과 함께 표기한다.

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers(minValue: int, maxValue: int, count: int) : List<int> =
        let rand = new Random((int)DateTime.Now.Ticks)
        let numbers = new List<int>()
        while (numbers.Count != count)
            let num = rand.Next(minValue, maxValue + 1)
            if (!numbers.Contains(num))
                numbers.Add(num)
        return numbers

    let main(args: string[]) : void =
        let numbers = lottoNumbers(1, 45, 6)
        Console.WriteLine(string.Join(", ", numbers))

타입 지정 생략

사실 F#의 타입 추론 기능은 C#보다 훨씬 강력해서 대부분의 경우 타입 지정을 생략해도 문제가 없다. 심지어 함수의 리턴 타입까지 생략해도 된다. 아래처럼 타입 지정을 전부 생략해도 앞의 코드와 의미는 같다:

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers(minValue, maxValue, count) =
        let rand = new Random((int)DateTime.Now.Ticks)
        let numbers = new List<int>()
        while (numbers.Count != count)
            let num = rand.Next(minValue, maxValue + 1)
            if (!numbers.Contains(num))
                numbers.Add(num)
        return numbers

    let main(args) =
        let numbers = lottoNumbers(1, 45, 6)
        Console.WriteLine(string.Join(", ", numbers))

F#에서는 특별한 이유가 없다면 타입 지정을 생략하는 것이 관례다. 그래서 언뜻 보면 파이썬 같은 동적 타입/스크립트 언어와 매우 유사한 느낌을 주는데, 실제로는 C#과 같은 정적 타입 기반 언어다.

함수 파라미터 형식 변경

F#에서는 함수 파라미터를 , 대신 빈 칸으로 구분하고, 파라미터 리스트 전체를 둘러싸는 괄호도 쓰지 않는다. 앞의 소스에서 ,()를 제거하면 다음과 같이 된다:

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers minValue maxValue count =
        let rand = new Random((int)DateTime.Now.Ticks)
        let numbers = new List<int>()
        while (numbers.Count != count)
            let num = rand.Next(minValue, maxValue + 1)
            if (!numbers.Contains(num))
                numbers.Add(num)
        return numbers

    let main args =
        let numbers = lottoNumbers 1 45 6
        Console.WriteLine(string.Join(", ", numbers))

단, F# 함수가 아닌 다른 .NET 언어에서 가져온 API 메쏘드는 여전히 C# 스타일로 (),를 써서 표기한다. 여기 소스에 보이는 Random(), Next(), Contains() 등이 모두 .NET BCL에서 가져온 것들이다. 더 자세하고 정확한 것은 조만간 따로 다루도록 하겠다.

제어문과 연산자 대체

F#에도 whileif문이 있는데 C#과 거의 비슷하다. F# 스타일로 고쳐 보면 다음과 같다:

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers minValue maxValue count =
        let rand = new Random((int)DateTime.Now.Ticks)
        let numbers = new List<int>()
        while numbers.Count <> count do
            let num = rand.Next(minValue, maxValue + 1)
            if not (numbers.Contains(num)) then
                numbers.Add(num)
        return numbers

    let main args =
        let numbers = lottoNumbers 1 45 6
        Console.WriteLine(string.Join(", ", numbers))

while문은 조건 뒤에 do를 붙이고, if문은 조건 뒤에 then을 붙인다.

F#은 ! 연산자를 다른 용도로 쓰기 때문에 C#의 !not 함수로 대체했다. 여기 not을 쓰면서 뒷부분 numbers.Contains(num) 전체를 ()로 감쌌는데, 이렇게 하지 않으면 F# 문법 규칙상 notnumbers.Contains가 뒤의 (num)보다 먼저 결합하므로 (not numbers.Contains)(num)처럼 뜻이 이상해져 버린다(결과는 물론 컴파일 에러). 이 문제는 특히 F# 함수와 C# 메쏘드를 섞어 쓸 때 빈번히 발생하는 문제라 주의가 필요하다.

같지 않음을 비교하는 연산자는 != 대신 <>로 쓴다.

newreturn 키워드 제거

new는 C#과 의미가 같지만 써도 되고 안써도 되는데 대개는 쓰지 않는다(관례상 쓰는 곳이 따로 있다). return 키워드는 F#에도 있긴 하지만 함수 리턴값과는 전혀 상관없는 용도로 사용한다. 실제로는 굳이 return같은 키워드가 필요하지 않은데, 그 이유는 함수 내에서 가장 마지막에 실행되는 식의 결과가 자동으로 리턴값이 되기 때문이다. 두 키워드를 제거하면 다음과 같이 된다:

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers minValue maxValue count =
        let rand = Random((int)DateTime.Now.Ticks)
        let numbers = List<int>()
        while numbers.Count <> count do
            let num = rand.Next(minValue, maxValue + 1)
            if not (numbers.Contains(num)) then
                numbers.Add(num)
        numbers

    let main args =
        let numbers = lottoNumbers 1 45 6
        Console.WriteLine(string.Join(", ", numbers))

C# 타입 캐스팅을 F# 함수로 대체

F#에서는 int, char, float, … 등의 기본 타입 이름 자체가 타입 변환 함수로도 쓰인다. 그래서 C#에서 (int)DateTime.Now.Ticks처럼 하던 타입 캐스팅이 F#에선 int DateTime.Now.Ticks처럼 int라는 함수를 호출하는 형태가 된다.

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers minValue maxValue count =
        let rand = Random(int DateTime.Now.Ticks)
        let numbers = List<int>()
        while numbers.Count <> count do
            let num = rand.Next(minValue, maxValue + 1)
            if not (numbers.Contains(num)) then
                numbers.Add(num)
        numbers

    let main args =
        let numbers = lottoNumbers 1 45 6
        Console.WriteLine(String.Join(", ", numbers))

또한 맨 마지막 줄의 string.JoinString.Join으로 고쳤는데, intstring 등을 C#에서처럼 메쏘드 호출에 쓰는 타입 별칭으로는 사용할 수 없다. 그래서 int.Parse(...) 같은 용법이 불가능하고 꼭 정식 타입 명칭을 써서 Int32.Parse(...)처럼 써야 한다. 이점은 약간 헷갈리는 언어 설계이긴 한데 요약하자면

let a: int = 3
let s: string = "a"
let b = int 3.14
let t = string 42

등은 허용되지만

let a = int.Parse("3") // 에러
let s = string.Join(", ", numbers) // 에러

는 안된다는 것이다.

[<EntryPoint>] 어트리뷰트의 사용

C#과 달리 F#은 정해진 이름의 메인 함수가 따로 없고 string[] 타입의 파라미터를 한개 갖고 리턴 타입이 int인 함수에 [<EntryPoint>] 어트리뷰트를 붙여 주면 메인 함수가 된다. C#에서는 어트리뷰트를 []로 둘러싸지만 F#에선 [<>]로 둘러싸는 것이 다를 뿐이다. 위의 소스에 반영해 보면 다음과 같이 된다:

namespace CSharpToFSharp

open System
open System.Collections.Generic

module Program =
    let lottoNumbers minValue maxValue count =
        let rand = Random(int DateTime.Now.Ticks)
        let numbers = List<int>()
        while numbers.Count <> count do
            let num = rand.Next(minValue, maxValue + 1)
            if not (numbers.Contains(num)) then
                numbers.Add(num)
        numbers

    [<EntryPoint>]
    let main args =
        let numbers = LottoNumbers 1 45 6
        Console.WriteLine(String.Join(", ", numbers))
        0

main 함수 마지막에 붙인 0main 함수의 리턴값이자 이 프로그램의 리턴값이 된다.

여기까지 하고 나면 F#으로의 변환이 마침내 끝났다! 이제 프로그램을 실행하면

42, 20, 22, 12, 25, 3

처럼 나오면 잘된 것이다.

마치며

지금까지 보인 것처럼 기본적으로 F#은

  • 공백으로 구분하는 들여쓰기 방식이 파이썬과 유사하다. 단, 탭 문자는 사용하지 못한다.
  • 거의 대부분의 경우 타입 지정을 생략할 수 있어서 언뜻 보면 파이썬 같은 동적 타입/스크립트 언어와 비슷하게 생겼다. 그렇지만 실제로는 C#과 같은 정적 타입 기반 컴파일 언어다.
  • 특별히 함수형 프로그래밍을 위해 설계되었지만 기존 명령형 프로그래밍 방식으로도 충분히 짤 수 있다. 글에서는 다루지 않았지만 클래스나 인터페이스 같은 OOP 구문들도 전부 지원한다.
  • C#에서 문법상의 각종 군더더기를 싹 걷어낸 형태와 매우 유사하므로 기본 문법을 익히기 쉽다.
  • C#을 포함한 다른 .NET 언어로 만든 API를 그대로 가져와 쓸 수 있다. 반대로 F#으로 만든 코드도 C#에서 그대로 불러와 사용 가능하다.

다음 글에서는 F#의 함수에 관해 더 자세히 알아보기로 한다.