튜플 형식

튜플 형식 (C# 참조)

여러 데이터 요소를 하나의 가벼운 구조로 묶고 싶을 때, 튜플(tuple) 기능이 아주 간결한 문법을 제공해요. 관련성은 있지만 엄밀히 같은 종류라 부르기 어려운 값들을 한데 모아 다룰 때 특히 유용하죠.

출처: Microsoft Learn

본문

튜플은 여러 데이터 요소를 가벼운 데이터 구조 하나로 묶는 간결한 문법을 제공하는 기능이에요. C#이 처음 등장한 초기 버전부터 써 왔던 기능이라, 최신 버전에서만 쓸 수 있는 문법이 아니라는 점도 참고해 두면 좋겠네요.

튜플 변수를 선언하고, 초기화하고, 그 데이터 멤버에 접근하는 방법은 아래 코드에서 확인할 수 있어요.

(double, int) t1 = (4.5, 3);
Console.WriteLine($"Tuple with elements {t1.Item1} and {t1.Item2}.");
// Output:
// Tuple with elements 4.5 and 3.

(double Sum, int Count) t2 = (4.5, 3);
Console.WriteLine($"Sum of {t2.Count} elements is {t2.Sum}.");
// Output:
// Sum of 3 elements is 4.5.

위 예시에서 보이듯 튜플 형식을 정의하려면 모든 데이터 멤버의 타입을 지정하고, 원하면 필드 이름도 함께 정해 줘요. 튜플 형식 안에 메서드를 정의할 수는 없지만, .NET이 제공하는 메서드는 그대로 사용할 수 있어요. 아래처럼 말이죠.

(double, int) t = (4.5, 3);
Console.WriteLine(t.ToString());
Console.WriteLine($"Hash code of {t} is {t.GetHashCode()}.");
// Output:
// (4.5, 3)
// Hash code of (4.5, 3) is 718460086.

튜플 형식은 같음 연산자 ==!=를 지원해요. 자세한 내용은 아래의 튜플 같음 절에서 다룰게요.

튜플 형식은 값 형식이고, 튜플 요소는 공개 필드(public field)예요. 이런 설계 덕에 튜플은 변경 가능한(mutable) 값 형식이 되는 거죠.

요소 개수는 원하는 만큼 크게 잡아도 돼요. 26개처럼 많은 요소를 가진 튜플도 아래처럼 선언할 수 있어요.

var t =
(1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18,
19, 20, 21, 22, 23, 24, 25, 26);
Console.WriteLine(t.Item26);  // output: 26

튜플의 활용 사례

튜플이 가장 흔하게 쓰이는 자리 중 하나가 바로 메서드의 반환 타입이에요. out 메서드 매개 변수를 여럿 정의하는 대신, 메서드 결과를 튜플 반환 타입으로 묶을 수 있어요. 다음 예시를 볼게요.

int[] xs = new int[] { 4, 7, 9 };
var limits = FindMinMax(xs);
Console.WriteLine($"Limits of [{string.Join(" ", xs)}] are {limits.min} and {limits.max}");
// Output:
// Limits of [4 7 9] are 4 and 9

int[] ys = new int[] { -9, 0, 67, 100 };
var (minimum, maximum) = FindMinMax(ys);
Console.WriteLine($"Limits of [{string.Join(" ", ys)}] are {minimum} and {maximum}");
// Output:
// Limits of [-9 0 67 100] are -9 and 100

(int min, int max) FindMinMax(int[] input)
{
    if (input is null || input.Length == 0)
    {
        throw new ArgumentException("Cannot find minimum and maximum of a null or empty array.");
    }

    // Initialize min to MaxValue so every value in the input
    // is less than this initial value.
    var min = int.MaxValue;
    // Initialize max to MinValue so every value in the input
    // is greater than this initial value.
    var max = int.MinValue;
    foreach (var i in input)
    {
        if (i < min)
        {
            min = i;
        }
        if (i > max)
        {
            max = i;
        }
    }
    return (min, max);
}

반환된 튜플 인스턴스를 그대로 다룰 수도 있고, 디스트럭처링을 통해 별도의 변수들로 나눠 받을 수도 있어요. 위 코드가 두 가지 방식을 모두 보여 주죠.

튜플 형식은 익명 형식(anonymous type) 대신에도 쓸 수 있어요. 예를 들어 LINQ 쿼리에서 그렇게 활용할 수 있죠. 자세한 내용은 익명 형식과 튜플 형식 중 선택하기 문서를 참고해요.

다만 튜플은 느슨하게 관련된 데이터 요소를 묶는 용도로 쓰는 게 일반적이에요. 공개 API(public API)에서는 튜플보다 클래스구조체 형식을 정의하는 쪽을 고려해 보는 게 좋아요.

튜플 필드 이름

튜플 필드 이름은 튜플 초기화 식이나 튜플 형식 정의에서 명시적으로 지정할 수 있어요.

var t = (Sum: 4.5, Count: 3);
Console.WriteLine($"Sum of {t.Count} elements is {t.Sum}.");

(double Sum, int Count) d = (4.5, 3);
Console.WriteLine($"Sum of {d.Count} elements is {d.Sum}.");

필드 이름을 지정하지 않으면, 튜플 초기화 식에서 대응하는 변수의 이름으로부터 컴파일러가 이름을 유추할 수도 있어요.

var sum = 4.5;
var count = 3;
var t = (sum, count);
Console.WriteLine($"Sum of {t.count} elements is {t.sum}.");

이 기능을 *튜플 프로젝션 이니셜라이저(tuple projection initializer)*라고 불러요. 다만 다음 경우에는 변수 이름이 튜플 필드 이름으로 투영되지 않아요.

  • 후보 이름이 튜플 형식의 멤버 이름과 겹치는 경우. 예를 들어 Item3, ToString, Rest 같은 이름이에요.
  • 후보 이름이 (명시적이든 암시적이든) 다른 튜플 필드 이름과 중복되는 경우.

이런 경우에는 필드 이름을 명시적으로 지정하거나, 기본 이름으로 필드에 접근하면 돼요.

튜플 필드의 기본 이름은 Item1, Item2, Item3처럼 이어져요. 필드 이름이 명시적으로 지정되었거나 유추되었다 하더라도 항상 기본 이름을 쓸 수 있어요.

var a = 1;
var t = (a, b: 2, 3);
Console.WriteLine($"The 1st element is {t.Item1} (same as {t.a}).");
Console.WriteLine($"The 2nd element is {t.Item2} (same as {t.b}).");
Console.WriteLine($"The 3rd element is {t.Item3}.");
// Output:
// The 1st element is 1 (same as 1).
// The 2nd element is 2 (same as 2).
// The 3rd element is 3.

튜플 할당튜플 같음 비교는 필드 이름을 고려하지 않아요.

컴파일 시점에 컴파일러는 기본이 아닌 필드 이름을 대응하는 기본 이름으로 바꿔요. 그래서 명시적으로 지정했거나 유추된 필드 이름은 런타임에서 사용할 수 없어요.

[!TIP] .NET 코드 스타일 규칙 IDE0037을 켜면, 유추된 튜플 필드 이름과 명시적인 튜플 필드 이름 중 어떤 쪽을 선호할지 설정할 수 있어요.

C# 12부터는 using 지시문으로 튜플 형식에 별칭(alias)을 지정할 수 있어요. 아래 예시는 허용되는 Min/Max 값을 가진 정수 두 개짜리 튜플 형식에 global using 별칭을 추가하고 있어요.

global using BandPass = (int Min, int Max);

별칭을 선언하고 나면, 그 튜플 형식 대신 BandPass라는 이름을 사용할 수 있어요.

BandPass bracket = (40, 100);
Console.WriteLine($"The bandpass filter is {bracket.Min} to {bracket.Max}");

별칭은 새로운 형식을 만들어 내지 않고 기존 형식의 동의어(synonym)만 만들 뿐이에요. BandPass 별칭으로 선언한 튜플은 그 밑바탕 튜플 형식과 똑같이 디스트럭처링할 수 있어요.

(int a , int b) = bracket;
Console.WriteLine($"The bracket is {a} to {b}");

튜플 할당이나 디스트럭처링과 마찬가지로, 튜플 멤버 이름은 일치할 필요가 없어요. 타입만 맞으면 되죠.

마찬가지로, 인자 수(arity)와 멤버 타입이 같은 두 번째 별칭도 원래 별칭과 서로 바꿔 쓸 수 있어요. 아래처럼 두 번째 별칭을 선언할 수 있고요.

using Range = (int Minimum, int Maximum);

Range 튜플을 BandPass 튜플에 할당할 수 있어요. 모든 튜플 할당이 그렇듯 필드 이름은 일치할 필요가 없고, 타입과 인자 수만 맞으면 돼요.

Range r = bracket;
Console.WriteLine($"The range is {r.Minimum} to {r.Maximum}");

튜플 형식의 별칭은 튜플을 쓸 때 의미 정보를 더 많이 담아 주는 역할을 해요. 새로운 형식을 만들지는 않죠. 진짜 타입 안전성이 필요하다면 위치 기반 record를 선언하는 쪽을 선택하는 게 좋아요.

튜플 할당과 디스트럭처링

C#은 다음 두 조건을 모두 만족하는 튜플 형식 사이의 할당을 지원해요.

  • 두 튜플 형식의 요소 개수가 같을 것.
  • 각 튜플 위치에서, 오른쪽 튜플 요소의 형식이 왼쪽 튜플의 대응 요소 형식과 같거나, 그 형식으로 암시적으로 변환될 수 있을 것.

튜플 요소 값은 튜플 요소의 순서를 따라 할당돼요. 이 할당 과정은 튜플 필드의 이름을 무시해요. 아래 예시를 볼게요.

(int, double) t1 = (17, 3.14);
(double First, double Second) t2 = (0.0, 1.0);
t2 = t1;
Console.WriteLine($"{nameof(t2)}: {t2.First} and {t2.Second}");
// Output:
// t2: 17 and 3.14

(double A, double B) t3 = (2.0, 3.0);
t3 = t2;
Console.WriteLine($"{nameof(t3)}: {t3.A} and {t3.B}");
// Output:
// t3: 17 and 3.14

할당 연산자 =를 쓰면 튜플 인스턴스를 디스트럭처링해서 별도의 변수들로 나눌 수 있어요. 방법은 여러 가지가 있어요.

  • 괄호 바깥에서 var 키워드를 써서 암시적 타입 변수를 선언하고 컴파일러가 타입을 유추하게 하는 방법.
var t = ("post office", 3.6);
var (destination, distance) = t;
Console.WriteLine($"Distance to {destination} is {distance} kilometers.");
// Output:
// Distance to post office is 3.6 kilometers.
  • 괄호 안에서 각 변수의 타입을 명시적으로 선언하는 방법.
var t = ("post office", 3.6);
(string destination, double distance) = t;
Console.WriteLine($"Distance to {destination} is {distance} kilometers.");
// Output:
// Distance to post office is 3.6 kilometers.
  • 괄호 안에서 일부 타입은 명시적으로, 나머지는 var로 암시적으로 선언하는 방법.
var t = ("post office", 3.6);
(var destination, double distance) = t;
Console.WriteLine($"Distance to {destination} is {distance} kilometers.");
// Output:
// Distance to post office is 3.6 kilometers.
  • 이미 있는 변수를 사용하는 방법.
var destination = string.Empty;
var distance = 0.0;

var t = ("post office", 3.6);
(destination, distance) = t;
Console.WriteLine($"Distance to {destination} is {distance} kilometers.");
// Output:
// Distance to post office is 3.6 kilometers.

디스트럭처링 식의 대상에는 이미 있는 변수와, 디스트럭처링 선언 안에서 새로 선언하는 변수를 함께 섞을 수 있어요.

디스트럭처링을 패턴 매칭과 결합하면 튜플 필드의 특성을 검사할 수도 있어요. 아래 예시는 정수 여러 개를 반복하면서 3으로 나누어 떨어지는 값들을 출력해요. xref:System.Int32.DivRem*?displayProperty=nameWithType의 튜플 결과를 디스트럭처링하고, 그중 Remainder가 0인 경우와 매칭하는 거죠.

for (int i = 4; i < 20;  i++)
{
    if (Math.DivRem(i, 3) is ( Quotient: var q, Remainder: 0 ))
    {
        Console.WriteLine($"{i} is divisible by 3, with quotient {q}");
    }
}

튜플과 다른 형식의 디스트럭처링에 대한 더 자세한 내용은 튜플 및 다른 형식 디스트럭처링 문서를 참고해요.

튜플 같음

튜플 형식은 ==!= 연산자를 지원해요. 이 연산자들은 왼쪽 피연산자의 멤버를 오른쪽 피연산자의 대응 멤버와, 튜플 요소의 순서에 따라 비교해요.

(int a, byte b) left = (5, 10);
(long a, int b) right = (5, 10);
Console.WriteLine(left == right);  // output: True
Console.WriteLine(left != right);  // output: False

var t1 = (A: 5, B: 10);
var t2 = (B: 5, A: 10);
Console.WriteLine(t1 == t2);  // output: True
Console.WriteLine(t1 != t2);  // output: False

위 예시에서 보이듯 ==!= 연산은 튜플 필드 이름을 고려하지 않아요.

두 튜플이 비교 가능하려면 다음 두 조건을 모두 만족해야 해요.

  • 두 튜플의 요소 개수가 같을 것. 예를 들어 t1t2의 요소 개수가 다르면 t1 != t2는 컴파일되지 않아요.
  • 각 튜플 위치에서, 왼쪽·오른쪽 튜플 피연산자의 대응 요소들이 ==!= 연산자로 비교 가능할 것. 예를 들어 (1, (2, 3)) == ((1, 2), 3)은 컴파일되지 않아요. 1(1, 2)와 비교할 수 없기 때문이죠.

==!= 연산자는 튜플을 단락(short-circuit) 방식으로 비교해요. 즉 비교하다가 같지 않은 요소 쌍을 만나거나, 튜플의 끝에 도달하면 연산을 멈춰요. 다만 어떤 비교도 시작되기 전에 모든 튜플 요소는 평가되어요. 아래 예시가 그 사실을 보여 주죠.

Console.WriteLine((Display(1), Display(2)) == (Display(3), Display(4)));

int Display(int s)
{
    Console.WriteLine(s);
    return s;
}
// Output:
// 1
// 2
// 3
// 4
// False

튜플을 out 매개 변수로 쓰기

보통은 out 매개 변수가 달린 메서드를 튜플을 반환하는 메서드로 리팩터링해요. 그런데 메서드 안에서 out 매개 변수의 타입이 튜플 형식이어야 하는 경우도 있어요. 튜플을 out 매개 변수로 다루는 방법을 아래 예시에서 볼게요.

var limitsLookup = new Dictionary<int, (int Min, int Max)>()
{
    [2] = (4, 10),
    [4] = (10, 20),
    [6] = (0, 23)
};

if (limitsLookup.TryGetValue(4, out (int Min, int Max) limits))
{
    Console.WriteLine($"Found limits: min is {limits.Min}, max is {limits.Max}");
}
// Output:
// Found limits: min is 10, max is 20

튜플 vs System.Tuple

C# 튜플은 xref:System.ValueTuple?displayProperty=nameWithType 형식을 사용해요. xref:System.Tuple?displayProperty=nameWithType 형식을 쓰는 튜플과는 다르죠. 두 튜플의 주된 차이는 이래요.

  • System.ValueTuple 형식은 값 형식이에요. 반면 System.Tuple 형식은 참조 형식이에요.
  • System.ValueTuple 형식은 변경 가능(mutable)해요. System.Tuple 형식은 변경 불가(immutable)하죠.
  • System.ValueTuple 형식의 데이터 멤버는 필드(field)예요. System.Tuple 형식의 데이터 멤버는 속성(property)이에요.

C# 언어 사양

더 자세한 내용은 아래를 참고해요.

더 알아보기