선언문
선언문 (Declaration statements)
선언문은 새로운 지역 변수(local variable), 지역 상수(local constant), 또는 지역 참조 변수(local reference variable, ref local)를 선언해요. 변수를 쓰려고 할 때 우리가 가장 먼저 하는 일이 바로 이 선언이죠.
출처: Microsoft Learn
본문
지역 변수를 선언하려면 그 타입을 지정하고 이름을 붙여요. 한 문장에서 같은 타입의 변수를 여러 개 선언할 수도 있어요. 다음 코드가 그 예시예요:
string greeting;
int a, b, c;
List<double> xs;
int a, b, c;처럼 쉼표로 나열하면 같은 타입 변수 여러 개를 한 번에 만들 수 있어요. 선언과 동시에 초기값을 넣어줄 수도 있죠:
string greeting = "Hello";
int a = 3, b = 2, c = a + b;
List<double> xs = new();
여기서 주의할 점이 하나 있어요. int a = 3, b = 2, c = a + b;처럼 초기화 식은 이전에 선언한 변수를 참조할 수 있어요. 그래서 c는 a + b, 즉 5라는 값을 갖는답니다.
위 예시들은 모두 변수의 타입을 직접 명시하고 있어요. 그런데 컴파일러에게 초기화 식을 보고 타입을 알아서 추론하게 할 수도 있어요. 그럴 때 타입 이름 대신 var 키워드를 쓰면 돼요. 자세한 내용은 암시적으로 형식화된 지역 변수 섹션에서 다뤄요.
지역 상수를 선언할 때는 const 키워드를 써요:
const string Greeting = "Hello";
const double MinLimit = -10.0, MaxLimit = -MinLimit;
const는 선언할 때 반드시 초기화해야 해요. 상수니까 값을 나중에 바꿀 수도 없고, 선언 시점에 값을 알 수 있어야 하죠. 만약 초기화 없이 const double MinLimit;처럼 쓰면 컴파일 오류가 나요.
지역 참조 변수에 대한 내용은 Reference variables 섹션에서 설명할게요.
암시적으로 형식화된 지역 변수 (Implicitly typed local variables)
지역 변수를 선언할 때 초기화 식으로부터 컴파일러가 타입을 알아서 추론하게 할 수 있어요. 그럴 때는 타입 이름 대신 var 키워드를 쓰면 돼요:
var greeting = "Hello";
Console.WriteLine(greeting.GetType()); // output: System.String
var a = 32;
Console.WriteLine(a.GetType()); // output: System.Int32
var xs = new List<double>();
Console.WriteLine(xs.GetType()); // output: System.Collections.Generic.List`1[System.Double]
var로 선언했다고 해서 타입이 없는 건 아니에요. 위 예시처럼 greeting은 System.String, a는 System.Int32로 확실하게 타입이 정해져요. 즉 암시적으로 형식화된 지역 변수는 강력한 타입(strongly typed)을 그대로 유지해요. 컴파일러가 초기화 식을 보고 정확한 타입을 정하는 것뿐이죠.
[!NOTE] nullable 인식 컨텍스트가 켜진 상태에서
var를 쓰고 초기화 식의 타입이 참조 타입이라면, 설령 그 초기화 식의 타입이 nullable이 아니더라도 컴파일러는 항상 nullable 참조 타입으로 추론해요. 즉var는 참조 타입을 다룰 때 기본적으로 nullable로 취급한다는 뜻이에요.
var를 가장 흔하게 쓰는 경우가 생성자 호출 식과 함께일 때예요. 변수 선언과 객체 생성 양쪽에 타입 이름을 반복해서 쓰지 않아도 되니까요:
var xs = new List<int>();
대신 target-typed new 식을 쓸 수도 있어요. 타입을 변수 선언 쪽에 한 번만 적고, new()로 객체를 만드는 방식이죠:
List<int> xs = new();
List<int>? ys = new();
익명 타입을 다룰 때는 반드시 암시적으로 형식화된 지역 변수를 써야 해요. 익명 타입에는 이름이 없어서 타입을 직접 쓸 수 없거든요. 다음 예시는 고객의 이름과 전화번호를 담는 익명 타입을 쓰는 쿼리 식이에요:
List<Customer> customers = new List<Customer>();
var fromPhoenix = from cust in customers
where cust.City == "Phoenix"
select new { cust.Name, cust.Phone };
foreach (var customer in fromPhoenix)
{
Console.WriteLine($"Name={customer.Name}, Phone={customer.Phone}");
}
위 예시에서 fromPhoenix 변수의 타입을 직접 명시할 수는 없어요. 그 타입은 xref:System.Collections.Generic.IEnumerable`1인데, 여기서 T가 익명 타입이라 그 이름을 제공할 방법이 없거든요. 그래서 var를 써야 하는 거예요. 같은 이유로 foreach 문에서 customer 반복 변수를 선언할 때도 var를 써야 해요.
암시적으로 형식화된 지역 변수에 대한 더 자세한 내용은 Implicitly-typed local variables 문서를 참고하세요.
한 가지 덧붙이면, 패턴 매칭에서도 var 키워드가 쓰여요. 그때는 var 패턴이라고 불러요.
참조 변수 (Reference variables)
지역 변수를 선언하면서 변수의 타입 앞에 ref 키워드를 붙이면 참조 변수(reference variable), 즉 ref local을 선언하는 거예요:
ref int aliasOfvariable = ref variable;
참조 변수는 다른 변수를 가리키는 변수인데, 그 가리켜지는 변수를 referent라고 불러요. 다시 말하면 참조 변수는 자신의 referent에 대한 *별명(alias)*이에요. 참조 변수에 값을 대입하면 그 값이 referent에 들어가고, 참조 변수의 값을 읽으면 referent의 값이 돌아와요. 다음 예시를 보면 그 동작이 바로 보일 거예요:
int a = 1;
ref int aliasOfa = ref a;
Console.WriteLine($"(a, aliasOfa) is ({a}, {aliasOfa})"); // output: (a, aliasOfa) is (1, 1)
a = 2;
Console.WriteLine($"(a, aliasOfa) is ({a}, {aliasOfa})"); // output: (a, aliasOfa) is (2, 2)
aliasOfa = 3;
Console.WriteLine($"(a, aliasOfa) is ({a}, {aliasOfa})"); // output: (a, aliasOfa) is (3, 3)
aliasOfa는 a를 가리키는 별명이에요. 그래서 a = 2로 바꾸면 aliasOfa를 읽어도 2가 나오고, 반대로 aliasOfa = 3으로 대입하면 그 값이 a에 들어가요. 별명과 원본이 서로 같은 변수를 공유한다고 생각하면 돼요.
ref 대입 연산자 = ref를 쓰면 참조 변수가 가리키는 referent를 바꿀 수 있어요:
void Display(int[] s) => Console.WriteLine(string.Join(" ", s));
int[] xs = [0, 0, 0];
Display(xs);
ref int element = ref xs[0];
element = 1;
Display(xs);
element = ref xs[^1];
element = 3;
Display(xs);
// Output:
// 0 0 0
// 1 0 0
// 1 0 3
위 예시에서 element 참조 변수는 처음에 첫 번째 배열 요소의 별명으로 초기화돼요. 그래서 element = 1을 하면 xs[0]이 1이 되죠. 그다음 element = ref xs[^1];로 ref 재대입을 해서 마지막 배열 요소를 가리키게 바꾸면, 이제 element = 3은 xs[^1], 즉 마지막 요소를 바꿔요.
ref readonly 지역 변수도 정의할 수 있어요. ref readonly 변수에는 값을 대입할 수 없지만, ref 재대입으로 가리키는 대상을 바꾸는 것은 가능해요:
int[] xs = [1, 2, 3];
ref readonly int element = ref xs[0];
// element = 100; error CS0131: The left-hand side of an assignment must be a variable, property or indexer
Console.WriteLine(element); // output: 1
element = ref xs[^1];
Console.WriteLine(element); // output: 3
ref readonly는 읽기 전용 별명이라 값을 쓰려고 하면 컴파일 오류가 나요. 주석에 적힌 것처럼 element = 100;은 CS0131 오류가 나죠. 그래도 가리키는 요소 자체를 바꾸는 ref 재대입은 할 수 있어서, element = ref xs[^1];로 마지막 요소를 읽을 수 있어요.
참조 반환(ref return)을 참조 변수에 대입할 수도 있어요:
using System;
public class NumberStore
{
private readonly int[] numbers = [1, 30, 7, 1557, 381, 63, 1027, 2550, 511, 1023];
public ref int GetReferenceToMax()
{
ref int max = ref numbers[0];
for (int i = 1; i < numbers.Length; i++)
{
if (numbers[i] > max)
{
max = ref numbers[i];
}
}
return ref max;
}
public override string ToString() => string.Join(" ", numbers);
}
public static class ReferenceReturnExample
{
public static void Run()
{
var store = new NumberStore();
Console.WriteLine($"Original sequence: {store.ToString()}");
ref int max = ref store.GetReferenceToMax();
max = 0;
Console.WriteLine($"Updated sequence: {store.ToString()}");
// Output:
// Original sequence: 1 30 7 1557 381 63 1027 2550 511 1023
// Updated sequence: 1 30 7 1557 381 63 1027 0 511 1023
}
}
이 예시에서 GetReferenceToMax 메서드는 returns-by-ref(참조로 반환하는) 메서드예요. 최댓값 자체를 돌려주는 게 아니라, 최댓값을 담고 있는 배열 요소의 별명인 참조 반환을 돌려줘요. Run 메서드는 그 참조 반환을 max 참조 변수에 대입해요. 그리고 max = 0으로 대입하면 store 인스턴스의 내부 저장 값이 실제로 바뀌어요. ref readonly 메서드도 정의할 수 있는데, ref readonly 메서드의 호출자는 그 참조 반환에 값을 대입할 수 없어요.
foreach 문의 반복 변수도 참조 변수로 쓸 수 있어요. 자세한 내용은 Iteration statements 문서의 foreach statement 섹션을 참고하세요.
성능이 중요한 상황에서 참조 변수와 참조 반환을 쓰면 값 복사 연산을 피할 수 있어서 성능이 좋아질 수 있어요. 큰 구조체나 컬렉션 요소를 매번 통째로 복사하는 대신 별명을 넘기면 그 비용이 사라지니까요.
컴파일러는 참조 변수가 자신의 referent보다 오래 살아남지 않는지, 즉 수명(lifetime) 전체에 걸쳐 유효한지 보장해줘요. 자세한 내용은 C# 언어 사양의 Ref safe contexts 섹션을 참고하세요.
ref 필드에 대한 내용은 ref 구조체 타입 문서의 ref fields 섹션을 보면 돼요.
scoped ref
문맥 키워드 scoped는 값의 수명(lifetime)을 제한해요. scoped 한정자는 ref-safe-to-escape 또는 safe-to-escape 수명을 각각 현재 메서드로 제한해요. scoped 한정자를 붙인다는 건 "내 코드가 이 변수의 수명을 더 늘리지 않는다"고 컴파일러에게 단언하는 것이에요.
scoped는 매개 변수나 지역 변수에 적용할 수 있어요. 타입이 ref struct일 때는 매개 변수와 지역 변수 모두에 적용할 수 있어요. 그 외의 경우에는 scoped 한정자를 지역 참조 변수에만 적용할 수 있어요. 이 규칙에는 ref 한정자로 선언된 지역 변수와 in, ref, out 한정자로 선언된 매개 변수도 포함돼요.
scoped 한정자는 구조체에서 선언한 메서드의 this, out 매개 변수, 그리고 타입이 ref struct인 ref 매개 변수에는 암시적으로 추가돼요. 그래서 이 경우에는 직접 scoped를 쓰지 않아도 수명이 자동으로 제한돼요.
C# 언어 사양
더 자세한 내용은 C# 언어 사양의 다음 섹션을 참고하세요:
scoped 한정자에 대한 더 자세한 내용은 C# 언어 사양의 The scoped modifier를 보세요.