F#의 클래스

F#의 클래스 (Classes)

이번 글에서는 F#에서 객체 지향 프로그래밍의 핵심이 되는 **클래스(class)**에 대해 다뤄요. 클래스는 속성(properties), 메서드(methods), 이벤트(events)를 가질 수 있는 객체를 표현하는 타입이에요. F#의 클래스 문법은 여타 .NET 언어와 조금 다르기 때문에, 각 구성 요소가 어떻게 동작하는지 하나씩 살펴볼게요.

출처: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/classes

본문

구문 (Syntax)

클래스 정의는 다음과 같이 생겼어요.

// Class definition:
type [access-modifier] type-name [type-params] [access-modifier] ( parameter-list ) [ as identifier ] =
[ class ]
[ inherit base-type-name(base-constructor-args) ]
[ let-bindings ]
[ do-bindings ]
member-list
...
[ end ]
// Mutually recursive class definitions:
type [access-modifier] type-name1 ...
and [access-modifier] type-name2 ...
...

설명 (Remarks)

클래스는 .NET 객체 타입에 대한 근본적인 설명을 나타내요. 클래스는 F#에서 객체 지향 프로그래밍을 지원하는 가장 기본적인 타입 개념이에요.

위 구문에서 type-name은 유효한 식별자면 무엇이든 될 수 있어요. type-params는 선택적인 제네릭 타입 매개변수를 나타내며, 꺾쇠괄호(<>) 안에 타입 매개변수 이름과 제약 조건을 적어요. 자세한 내용은 GenericsConstraints를 참고해요. parameter-list는 생성자 매개변수를 설명해요. 첫 번째 접근 한정자는 타입 자체에, 두 번째 접근 한정자는 주 생성자(primary constructor)에 적용돼요. 둘 다 기본값은 public이에요.

기본 클래스는 inherit 키워드로 지정해요. 이때 기본 클래스의 생성자에 넘길 인자들을 괄호 안에 반드시 제공해야 해요.

클래스에 국한된(local) 필드나 함수 값을 선언할 때는 let 바인딩을 사용하며, let 바인딩의 일반 규칙을 따라야 해요. do 바인딩 섹션에는 객체가 생성될 때 실행할 코드를 넣어요.

member-list에는 추가 생성자, 인스턴스/정적 메서드 선언, 인터페이스 선언, 추상 바인딩, 프로퍼티 및 이벤트 선언이 들어가요. 이들은 Members에서 자세히 설명해요.

선택적인 as 키워드와 함께 쓰는 식별자는 인스턴스 변수, 즉 **자기 식별자(self identifier)**에 이름을 붙여줘요. 이 이름은 타입 정의 안에서 해당 타입의 인스턴스를 가리킬 때 사용해요. 자세한 내용은 이 문서의 자기 식별자(Self Identifiers) 섹션을 참고해요.

정의의 시작과 끝을 표시하는 classend 키워드는 생략할 수 있어요.

서로를 참조하는 타입인 **상호 재귀 타입(mutually recursive types)**은 상호 재귀 함수처럼 and 키워드로 이어 붙여요. 예시는 상호 재귀 타입(Mutually Recursive Types) 섹션을 참고해요.

생성자 (Constructors)

생성자는 클래스 타입의 인스턴스를 만드는 코드예요. F#의 클래스 생성자는 다른 .NET 언어와는 조금 다르게 동작해요. F# 클래스에는 항상 **주 생성자(primary constructor)**가 있는데, 그 인자는 타입 이름 뒤에 오는 parameter-list로 표현되고, 몸체는 클래스 선언 앞부분의 let(그리고 let rec) 바인딩과 그 뒤에 따라오는 do 바인딩으로 구성돼요. 주 생성자의 인자는 클래스 선언 전체에서 사용할 수 있어요.

추가 생성자는 new 키워드로 멤버를 추가하는 방식으로 만들 수 있어요.

new ( argument-list ) = constructor-body

new 생성자의 몸체는 클래스 선언 맨 위에 지정된 주 생성자를 반드시 호출해야 해요.

다음 예시를 살펴볼게요. 아래 코드에서 MyClass는 인자를 두 개 받는 주 생성자와 인자를 받지 않는 또 다른 생성자, 이렇게 두 개의 생성자를 가져요.

type MyClass1(x: int, y: int) =
    do printfn "%d %d" x y
    new() = MyClass1(0, 0)

let 및 do 바인딩 (let and do Bindings)

클래스 정의 안의 letdo 바인딩은 주 클래스 생성자의 몸체를 이루며, 따라서 클래스 인스턴스가 만들어질 때마다 실행돼요. let 바인딩이 함수라면 멤버로 컴파일되고, 어떤 함수나 멤버에서도 사용되지 않는 값이라면 생성자에 국한된 변수로 컴파일돼요. 그 외의 경우에는 클래스의 필드로 컴파일돼요. 뒤따르는 do 식은 주 생성자로 컴파일돼서 모든 인스턴스의 초기화 코드를 실행해요. 모든 추가 생성자는 항상 주 생성자를 호출하기 때문에, 어느 생성자를 부르든 let 바인딩과 do 바인딩은 항상 실행돼요.

let 바인딩으로 만든 필드는 클래스의 메서드와 프로퍼티 전체에서 접근할 수 있어요. 다만 정적 메서드에서는 접근할 수 없는데, 설령 그 정적 메서드가 인스턴스 변수를 매개변수로 받더라도 마찬가지예요. 그리고 자기 식별자가 있다면 그걸로도 접근할 수 없어요.

자기 식별자 (Self Identifiers)

자기 식별자는 현재 인스턴스를 나타내는 이름이에요. C#이나 C++의 this, Visual Basic의 Me와 비슷한 역할이에요. 자기 식별자가 클래스 정의 전체에서 유효하게 할지, 아니면 개별 메서드에서만 유효하게 할지에 따라 두 가지 방식으로 정의할 수 있어요.

클래스 전체에 유효한 자기 식별자를 정의하려면, 생성자 매개변수 목록의 닫는 괄호 뒤에 as 키워드를 쓰고 식별자 이름을 지정해요.

하나의 메서드에만 유효한 자기 식별자를 정의하려면, 멤버 선언에서 메서드 이름 바로 앞에 자기 식별자를 쓰고 구분자로 마침표(.)를 붙여요.

다음 코드는 자기 식별자를 만드는 두 방법을 보여줘요. 첫 번째 줄에서는 as 키워드로 자기 식별자를 정의했고, 다섯 번째 줄에서는 this라는 식별자를 사용해 그 범위가 PrintMessage 메서드로만 제한된 자기 식별자를 정의했어요.

type MyClass2(dataIn) as self =
    let data = dataIn
    do
        self.PrintMessage()
    member this.PrintMessage() =
        printf "Creating MyClass2 with Data %d" data

다른 .NET 언어와 달리 F#에서는 자기 식별자 이름을 자유롭게 지을 수 있어요. self, Me, this 같은 이름으로 제한되지 않아요.

as 키워드로 선언한 자기 식별자는 기본 생성자가 실행된 뒤에야 초기화돼요. 그래서 기본 생성자 이전이나 내부에서 이 식별자를 사용하면 런타임에 System.InvalidOperationException: The initialization of an object or value resulted in an object or value being accessed recursively before it was fully initialized. 예외가 발생해요. 기본 생성자 이후, 예를 들어 let 바인딩이나 do 바인딩 안에서는 자기 식별자를 자유롭게 사용할 수 있어요.

제네릭 타입 매개변수 (Generic Type Parameters)

제네릭 타입 매개변수는 꺾쇠괄호(<>) 안에 표기하며, 작은따옴표(') 뒤에 식별자를 붙이는 형태예요. 여러 개의 제네릭 타입 매개변수는 쉼표로 구분해요. 제네릭 타입 매개변수는 선언 전체에서 유효해요. 다음 코드는 제네릭 타입 매개변수를 지정하는 방법을 보여줘요.

type MyGenericClass<'a>(x: 'a) =
    do printfn "%A" x

타입이 사용될 때 타입 인자는 추론돼요. 아래 코드에서 추론된 타입은 튜플의 시퀀스예요.

let g1 = MyGenericClass(seq { for i in 1..10 -> (i, i * i) })

상속 지정 (Specifying Inheritance)

inherit 절은 직접 기본 클래스가 있다면 그 클래스를 가리켜요. F#에서는 직접 기본 클래스가 하나만 허용돼요. 클래스가 구현하는 인터페이스는 기본 클래스로 취급되지 않아요. 인터페이스는 Interfaces 항목에서 다뤄요.

파생 클래스에서 기본 클래스의 메서드와 프로퍼티에 접근하려면 언어 키워드 base를 식별자로 쓰고, 그 뒤에 마침표(.)와 멤버 이름을 붙여요.

자세한 내용은 Inheritance를 참고해요.

멤버 섹션 (Members Section)

정적 메서드나 인스턴스 메서드, 프로퍼티, 인터페이스 구현, 추상 멤버, 이벤트 선언, 추가 생성자 등을 이 섹션에서 정의할 수 있어요. letdo 바인딩은 이 섹션에 올 수 없어요. 멤버는 클래스 외에도 다양한 F# 타입에 추가할 수 있기 때문에, 별도의 Members 항목에서 따로 다뤄요.

상호 재귀 타입 (Mutually Recursive Types)

서로를 순환적으로 참조하는 타입을 정의할 때는 and 키워드로 타입 정의를 이어 붙여요. 첫 번째 정의를 제외한 나머지에서는 type 키워드를 and 키워드로 바꿔요. 아래처럼요.

open System.IO

type Folder(pathIn: string) =
    let path = pathIn
    let filenameArray: string array = Directory.GetFiles(path)
    member this.FileArray = Array.map (fun elem -> new File(elem, this)) filenameArray

and File(filename: string, containingFolder: Folder) =
    member this.Name = filename
    member this.ContainingFolder = containingFolder

let folder1 = new Folder(".")

for file in folder1.FileArray do
    printfn "%s" file.Name

출력은 현재 디렉터리에 있는 모든 파일의 목록이에요.

클래스, 공용체, 레코드, 구조체는 언제 써야 할까 (When to Use Classes, Unions, Records, and Structures)

선택할 수 있는 타입이 다양하기 때문에, 상황에 맞는 타입을 고르려면 각 타입이 무엇을 위해 설계됐는지 잘 이해하고 있어야 해요. 클래스는 객체 지향 프로그래밍 맥락에서 사용하도록 설계됐어요. 객체 지향 프로그래밍은 .NET Framework용으로 작성되는 애플리케이션에서 지배적인 패러다임이에요. F# 코드가 .NET Framework나 다른 객체 지향 라이브러리와 긴밀하게 연동해야 하거나, 특히 UI 라이브러리처럼 객체 지향 타입 계층을 확장해야 한다면 클래스가 적절한 선택이에요.

객체 지향 코드와 긴밀하게 연동할 필요가 없거나, 자기 완결적이라 객체 지향 코드와 자주 얽힐 일이 없는 코드를 작성한다면 클래스·레코드·판별 공용체를 적절히 섞어 쓰는 걸 고려해 보세요. 잘 설계된 판별 공용체 하나와 적절한 패턴 매칭 코드는 객체 계층 구조의 더 단순한 대안으로 자주 쓰일 수 있어요. 판별 공용체에 대한 자세한 내용은 Discriminated Unions을 참고해요.

레코드는 클래스보다 단순하다는 장점이 있어요. 하지만 타입이 요구하는 것이 단순함으로 해결할 수 있는 수준을 넘어서면 레코드는 적절하지 않아요. 레코드는 기본적으로 값의 단순한 집합일 뿐이라, 사용자 지정 동작을 수행하는 별도의 생성자도, 숨겨진 필드도, 상속이나 인터페이스 구현도 없어요. 레코드에 프로퍼티나 메서드 같은 멤버를 추가해서 동작을 더 복잡하게 만들 수는 있지만, 레코드에 저장되는 필드는 여전히 값의 단순한 집합이에요. 레코드에 대한 자세한 내용은 Records를 참고해요.

구조체도 작은 데이터의 집합에 유용하지만, 클래스·레코드와 달리 .NET 값 타입(value type)이라는 점에서 차이가 있어요. 클래스와 레코드는 .NET 참조 타입(reference type)이에요. 값 타입과 참조 타입의 의미론은 값 타입이 값으로 전달된다는 점에서 달라요. 즉 매개변수로 전달되거나 함수에서 반환될 때 비트 단위로 복사된다는 뜻이에요. 또한 스택에 저장되거나, 필드로 사용될 때는 힙의 별도 위치에 저장되는 대신 부모 객체 안에 내장돼요. 따라서 힙에 접근하는 오버헤드가 문제가 되는 자주 접근하는 데이터에는 구조체가 적절해요. 구조체에 대한 자세한 내용은 Structs를 참고해요.

더 알아보기 (Learn more)