값 형식

값 형식 (C# reference)

C#의 모든 타입은 크게 값 형식(value types)참조 형식(reference types) 두 갈래로 나뉘어요. 값 형식 변수는 그 타입의 인스턴스 자체를 담고 있어서, 참조 형식처럼 '인스턴스를 가리키는 참조'를 담는 것과는 동작이 달라요. 이 차이를 제대로 이해하면 변수를 복사하거나 메서드에 넘길 때 값이 어떻게 움직이는지 예측할 수 있게 돼요.

출처: Microsoft Learn

본문

할당 연산자(=), 메서드에 인자를 전달할 때, 메서드 결과를 반환할 때 — 이 세 가지 상황에서는 기본적으로 변수의 값을 복사해요. 값 형식 변수라면 그에 해당하는 타입 인스턴스를 통째로 복사한다고 보면 돼요. 아래 예시가 이 동작을 그대로 보여줍니다.

using System;

public struct MutablePoint
{
    public int X;
    public int Y;

    public MutablePoint(int x, int y) => (X, Y) = (x, y);

    public override string ToString() => $"({X}, {Y})";
}

public class Program
{
    public static void Main()
    {
        var p1 = new MutablePoint(1, 2);
        var p2 = p1;
        p2.Y = 200;
        Console.WriteLine($"{nameof(p1)} after {nameof(p2)} is modified: {p1}");
        Console.WriteLine($"{nameof(p2)}: {p2}");

        MutateAndDisplay(p2);
        Console.WriteLine($"{nameof(p2)} after passing to a method: {p2}");
    }

    private static void MutateAndDisplay(MutablePoint p)
    {
        p.X = 100;
        Console.WriteLine($"Point mutated in a method: {p}");
    }
}
// Expected output:
// p1 after p2 is modified: (1, 2)
// p2: (1, 200)
// Point mutated in a method: (100, 200)
// p2 after passing to a method: (1, 200)

예시를 따라가 보면, p1p2에 복사한 뒤 p2.Y를 바꿔도 p1은 그대로 (1, 2)예요. 또 p2를 메서드에 넘겨서 그 안에서 p.X를 바꿔도 메서드가 끝나면 원래 변수인 p2에는 영향이 없고요. 값 형식 변수에 대한 연산은 그 변수에 저장된 그 인스턴스에만 영향을 준다는 뜻이에요.

한 가지 짚고 넘어갈 경우가 있어요. 값 형식이 참조 형식 데이터 멤버를 하나 포함한다면, 값 형식 인스턴스를 복사할 때는 그 참조 형식 인스턴스에 대한 참조만 복사돼요. 즉 복사본과 원본 값 형식 인스턴스는 같은 참조 형식 인스턴스를 공유하게 되죠. 이 동작을 아래 예시에서 확인해 볼게요.

using System;
using System.Collections.Generic;

public struct TaggedInteger
{
    public int Number;
    private List<string> tags;

    public TaggedInteger(int n)
    {
        Number = n;
        tags = new List<string>();
    }

    public void AddTag(string tag) => tags.Add(tag);

    public override string ToString() => $"{Number} [{string.Join(\", \", tags)}]";
}

public class Program
{
    public static void Main()
    {
        var n1 = new TaggedInteger(0);
        n1.AddTag("A");
        Console.WriteLine(n1);  // output: 0 [A]

        var n2 = n1;
        n2.Number = 7;
        n2.AddTag("B");

        Console.WriteLine(n1);  // output: 0 [A, B]
        Console.WriteLine(n2);  // output: 7 [A, B]
    }
}

여기서 n2 = n1로 복사했는데, 출력을 보면 n10 [A, B]가 됐어요. n2.AddTag("B")의 영향이 n1에도 나타난 거죠. 정수 Number 값 자체는 각자 따로 복사되지만, tags 리스트는 같은 인스턴스를 공유하기 때문에 태그 추가가 양쪽에 보이는 거예요. 값 형식 안의 참조 멤버가 이렇게 얕은 복사(shallow copy)처럼 동작한다는 점을 기억해 두면 좋아요.

[!NOTE] 코드를 더 안전하고 오류에 덜 취약하게 만들려면 불변(immutable) 값 형식을 정의하고 사용하는 게 좋아요. 이 문서는 설명을 위해 가변(mutable) 값 형식만 사용합니다.

값 형식의 종류와 타입 제약 조건

값 형식은 크게 다음 세 가지 중 하나예요.

  • 구조체 형식(structure type) — 데이터와 관련 기능을 하나로 묶어요.
  • 열거형 형식(enumeration type) — 이름 붙은 상수들의 집합으로 정의되며, 하나의 선택지 또는 선택지의 조합을 나타내요.
  • 공용체 선언(union declaration) — 값이 나타낼 수 있는 case 형식들의 닫힌 집합을 정의해요.

nullable 값 형식 T?는 기반이 되는 값 형식 T의 모든 값에 null이라는 하나의 값을 더 추가한 타입이에요. 값 형식 변수에는 기본적으로 null을 할당할 수 없고, 오직 nullable 값 형식인 경우에만 가능해요.

제네릭 타입 매개변수가 non-nullable 값 형식임을 지정하려면 struct 제약 조건을 쓰면 돼요. 구조체 형식과 열거형 형식 모두 struct 제약을 만족해요. 반면 타입 매개변수가 열거형임을 지정하려면 기본 클래스 제약 조건에 System.Enum을 쓰면 되는데, 이걸 enum 제약 조건이라고 불러요.

기본 제공 값 형식

C#은 다음과 같은 기본 제공 값 형식들을 제공하는데, 이들을 단순 형식(simple types) 이라고도 불러요.

모든 단순 형식은 구조체 형식이에요. 다만 다른 구조체 형식과 달리 몇 가지 추가 연산을 허용한다는 차이가 있어요.

  • 리터럴을 사용해 단순 형식의 값을 만들 수 있어요. 예를 들어 'A'char 형식의 리터럴이고, 2001int 형식의 리터럴이며, 12.34mdecimal 형식의 리터럴이에요.
  • const 키워드를 사용해 단순 형식의 상수를 선언할 수 있어요. 예를 들어 const decimal = 12.34m처럼 정의할 수 있죠. 다른 구조체 형식의 상수는 선언할 수 없어요.
  • 피연산자가 모두 단순 형식의 상수인 상수 식컴파일 타임에 계산돼요.

한편 값 튜플은 값 형식이긴 하지만 단순 형식은 아니라는 점을 구분해 두면 좋아요.

더 알아보기