C# 형식 시스템: 값 형식과 참조 형식

C# 형식 시스템: 값 형식과 참조 형식

C#은 강력한 형식(strongly typed) 언어예요. 변수든 상수든 식이든, 모든 값이 하나의 형식을 갖고 있고, 컴파일러가 코드의 모든 연산이 그 형식에 맞는지 검사해서 타입 안전성(type safety)을 보장해 줘요. 예를 들어 int끼리는 더할 수 있지만 intbool은 더할 수 없어요. 이런 검사가 컴파일 타임에 일어나니까, 실행 전에 많은 실수를 미리 잡을 수 있죠.

형식 안전성은 코드가 실행되기 전인 컴파일 타임에 오류를 잡아줘요. 게다가 컴파일러는 형식 정보를 메타데이터로 실행 파일에 함께 넣기 때문에, 공통 언어 런타임(CLR)이 런타임에 추가 안전 검사까지 해 줍니다. 출발점은 크게 두 축인데, 하나는 형식에 대한 선언 방법이고, 다른 하나는 값 형식과 참조 형식의 차이예요.

출처: The C# type system — Microsoft Learn

형식을 가진 변수 선언하기

변수를 선언할 때는 형식을 명시하거나, var를 써서 컴파일러가 할당된 값으로부터 형식을 유추하게 할 수 있어요.

// Explicit type:
int count = 10;
double temperature = 36.6;

// Compiler-inferred type:
var name = "C#";
var items = new List<string> { "one", "two", "three" };

메서드의 매개변수와 반환값도 형식을 가져요. 아래 메서드는 stringint를 받아 string을 반환합니다.

static string GetGreeting(string name, int visitCount)
{
    return visitCount switch
    {
        1 => $"Welcome, {name}!",
        _ => $"Welcome back, {name}! Visit #{visitCount}."
    };
}

한번 선언한 변수는 형식을 바꾸거나, 선언된 형식과 맞지 않는 값을 할당할 수 없어요. 값을 다른 형식으로 변환하는 건 가능한데, 데이터를 잃지 않는 암시적 변환(implicit conversion) 은 컴파일러가 자동으로 수행하고, 명시적 변환(cast) 은 코드에서 직접 표현해야 해요.

내장 형식과 사용자 정의 형식

C#은 정수, 부동 소수점, bool, char, string 같은 내장 형식(built-in types) 을 제공해요. 모든 C# 프로그램은 별도 참조 없이 이 내장 형식을 쓸 수 있죠.

내장 형식 밖에 자신만의 형식을 만들 때 쓰는 구성 요소들이 여러 가지 있어요.

  • 클래스(Classes) — 동작과 복잡한 객체를 모델링하는 참조 형식이에요. 상속과 다형성을 지원해요.
  • 구조체(Structs) — 작고 가벼운 데이터를 위한 값 형식이에요. 각 변수가 자기만의 복사본을 가져요.
  • 레코드(Records) — 컴파일러가 생성하는 동등성, ToString, 그리고 with 식을 통한 비파괴적 변경이 추가된 클래스 또는 구조체예요.
  • 인터페이스(Interfaces) — 어떤 클래스나 구조체가 구현할 수 있는 멤버를 정의하는 계약이에요.
  • 열거형(Enumerations) — 요일이나 파일 접근 모드 같은 명명된 정수 상수 집합이에요.
  • 튜플(Tuples) — 명명된 형식을 만들지 않고 관련된 값들을 묶는 가벼운 구조적 타입이에요.
  • 제네릭(Generics)List<T>, Dictionary<TKey, TValue>처럼 형식으로 매개화된 구성 요소예요. 서로 다른 형식에 같은 로직을 재사용하면서도 형식 안전성을 지켜 줘요.

값 형식과 참조 형식

C#의 모든 형식은 값 형식(value type) 이거나 참조 형식(reference type) 이에요. 이 구분이 변수가 데이터를 어떻게 저장하고, 대입(assignment)이 어떻게 동작하는지를 결정해요.

값 형식은 데이터를 직접 담아요. 값 형식을 새 변수에 대입하면 런타임이 데이터를 복사하죠. 그래서 한 변수를 바꿔도 다른 변수에는 영향이 없어요. 구조체, 열거형, 내장 숫자 형식이 전부 값 형식이에요.

참조 형식은 관리되는 힙(managed heap)에 있는 객체에 대한 참조를 담아요. 참조 형식을 새 변수에 대입하면 두 변수가 같은 객체를 가리키게 되죠. 그래서 한 변수를 통해 바꾼 내용이 다른 변수에서도 보여요. 클래스, 배열, 대리자, 문자열이 참조 형식이에요.

public readonly record struct Coords(int X, int Y);
// Value type: each variable holds its own copy
var point1 = new Coords(3, 4);
var point2 = point1;
Console.WriteLine($"point1: ({point1.X}, {point1.Y})");
Console.WriteLine($"point2: ({point2.X}, {point2.Y})");
// point1 and point2 are independent copies

// Reference type: both variables refer to the same object
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1;
list2.Add(4);
Console.WriteLine($"list1 count: {list1.Count}"); // 4 — same object

모든 형식은 궁극적으로 System.Object에서 파생돼요. 값 형식은 그 아래의 System.ValueType에서 파생되고, 그 ValueType이 다시 object에서 파생되죠. 이렇게 통일된 계층을 공통 형식 시스템(Common Type System, CTS) 이라고 불러요.

어떤 종류의 형식을 고를까

새 형식을 정의할 때 고르는 종류가 코드가 동작하는 방식을 결정해요. 첫 판단을 위한 대략적인 지침은 이래요.

  • 튜플 — 명명된 형식이나 동작이 필요 없는 일시적인 값 묶음.
  • struct 또는 record struct — 대략 64바이트 이하의 작은 데이터, 값 의미론, 또는 불변성. 레코드 구조체는 값 기반 동등성과 with 식을 추가해 줘요.
  • record class — 값 기반 동등성, ToString, 비파괴적 변경이 곁들여진 주로 데이터 중심. 상속을 지원해요.
  • class — 복잡한 동작, 다형성, 또는 가변 상태. 대부분의 사용자 정의 형식이 클래스예요.
  • interface — 서로 무관한 형식들이 구현할 수 있는 계약. 정체성보다 능력(capability)을 정의해요.
  • enum — 상태 코드나 옵션 같은 고정된 명명 상수 집합.

여러 선택이 동시에 타당한 경우도 많아요.

컴파일 타임 형식과 런타임 형식

변수는 컴파일 타임과 런타임에 서로 다른 형식을 가질 수 있어요. 컴파일 타임 형식은 소스 코드에 선언되거나 유추된 형식이고, 런타임 형식은 변수가 가리키는 인스턴스의 실제 형식이에요. 런타임 형식은 컴파일 타임 형식과 같거나, 그로부터 파생되거나, 그것을 구현하는 형식이어야 합니다.

// Compile-time and run-time types match:
string message = "Hello, world!";

// Compile-time type differs from run-time type:
object boxed = "This is a string at run time";
IEnumerable<char> characters = "abcdefghijklmnopqrstuvwxyz";

위 예에서 boxed의 컴파일 타임 형식은 object지만 런타임 형식은 string이에요. stringobject에서 파생되기 때문에 대입이 성립하죠. 컴파일 타임 형식은 오버로드 결정과 사용 가능한 변환을 제어하고, 런타임 형식은 가상 메서드 디스패치, is 식, switch 식을 제어해요.

더 알아보기 (Learn more)