(C#) 11. 코드의 흐름 제어 (ref, out)
값을 복사해 넘기는 기본 동작과 ref/out의 차이를 Swap과 Divide로 확인했다. 클래스를 ref 없이 넘겼을 때 멤버 수정은 반영되는데 재할당은 안 되는 이유까지 정리했다.
복사본이냐 원본이냐
10편에서 매개변수가 복사본이라는 걸 봤다. 그래서 값을 두 개 이상 돌려주거나, 넘긴 변수 자체를 바꿔야 하는 상황에서 막힌다.
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
using System;
namespace ref_out
{
class Program
{
static void Divide(int a, int b, out int result1, out int result2)
{
result1 = a / b;
result2 = a % b;
}
static void Swap(ref int a, ref int b)
{
int temp = a;
a = b;
b = temp;
}
// 덧셈 함수
static void AddOne(ref int number)
{
number = number + 1;
}
static int AddOne2(int number)
{
return number + 1;
}
static void Main(string[] args)
{
/////////////////////////////////////////////////// ref
// 복사(짭퉁) 참조(진퉁)
int a = 0;
Program.AddOne(ref a);
Console.WriteLine(a);
int b = Program.AddOne2(a);
a = b;
//a = Program.AddOne2(a);
Console.WriteLine(a);
////////////////////////////////////////////////// swap
int num1 = 1;
int num2 = 2;
Program.Swap(ref num1, ref num2);
Console.WriteLine(num1);
Console.WriteLine(num2);
/////////////////////////////////////////////////// out
num1 = 10;
num2 = 3;
int result1;
int result2;
Divide(10, 3, out result1, out result2);
Console.WriteLine(result1);
Console.WriteLine(result2);
}
}
}
주석의 “복사(짭퉁) 참조(진퉁)”이 이 편의 요지다.
Swap 은 ref 없이는 불가능하다
교환 함수가 ref의 필요성을 제일 잘 보여준다.
1
2
3
4
5
6
static void Swap(int a, int b) // ref 없이
{
int temp = a;
a = b;
b = temp;
}
이건 아무 일도 안 한다. 함수 안의 a, b는 복사본이고, 그 둘을 바꿔봐야 함수가 끝나면 사라진다. 컴파일도 되고 실행도 되는데 결과만 안 바뀐다.
ref를 붙이면 원본을 직접 가리키므로 밖의 num1, num2가 교환된다.
값을 하나만 바꾸는 거라면 ref 대신 반환값이 낫다는 걸 10편에서 적었다. 그런데 교환은 바꿔야 할 게 두 개라 반환값 하나로는 안 된다. 이럴 때가 ref가 맞는 자리다.
ref 와 out 의 차이
둘 다 원본을 넘기는데, 초기화 책임이 반대다.
ref | out | |
|---|---|---|
| 호출 전 초기화 | 필요하다 | 필요 없다 |
| 함수 안에서 대입 | 선택 | 반드시 해야 한다 |
| 의미 | 값을 주고받는다 | 값을 받아온다 |
1
2
3
4
5
int x;
AddOne(ref x); // 컴파일 에러: 할당되지 않은 지역 변수를 사용했습니다
int result1; // 초기화 안 해도 된다
Divide(10, 3, out result1, out result2);
ref는 함수가 기존 값을 읽을 수도 있으니 값이 들어 있어야 한다. out은 함수가 무조건 새 값을 넣어줄 것이므로 들어 있던 값은 의미가 없다.
out은 함수 쪽에도 제약이 걸린다. 모든 경로에서 대입해야 컴파일된다.
1
2
3
4
5
static void Bad(out int r)
{
if (DateTime.Now.Second > 30) r = 1;
// 에러: out 매개 변수 'r'에 할당해야 합니다
}
10편에서 본 “모든 경로에서 return” 검사와 같은 부류다. C#이 컴파일 단계에서 잡아주는 것들이 꽤 있다.
out 은 값을 여러 개 돌려줄 때
Divide가 몫과 나머지를 한 번에 준다. 반환값은 하나뿐이니 나머지 하나를 out으로 받는다.
int.TryParse가 이 형태의 대표적인 예다. 6편에서 썼던 그것이다.
1
if (int.TryParse(input, out int choice)) { }
성공 여부는 반환값으로, 변환된 값은 out으로 준다. “성공했는지”와 “결과가 무엇인지”를 동시에 돌려줘야 하는 구조라 이 방식이 맞다.
C# 7부터 호출하는 자리에서 바로 선언할 수 있다. 위에 미리 int result1;을 써둘 필요가 없어졌다.
1
2
Divide(10, 3, out int q, out int r);
Console.WriteLine($"{q} ... {r}");
값이 필요 없으면 out _로 버릴 수도 있다.
1
if (int.TryParse(input, out _)) { /* 숫자인지만 확인 */ }
요즘은 튜플이 더 낫다
out을 여러 개 쓰면 함수 시그니처가 길어지고 호출부도 지저분해진다. C# 7부터는 값 여러 개를 그냥 돌려줄 수 있다.
1
2
3
4
5
6
7
static (int quotient, int remainder) Divide(int a, int b)
{
return (a / b, a % b);
}
var (q, r) = Divide(10, 3);
Console.WriteLine($"{q} ... {r}");
이름을 붙일 수 있어서 무엇이 몫이고 무엇이 나머지인지 헷갈리지 않는다. out은 이제 TryParse처럼 굳어진 패턴에서 주로 쓰게 됐다.
클래스를 넘기면 이야기가 달라진다
여기가 제일 헷갈렸던 부분이다. int 같은 값 형식은 복사되지만, 클래스는 참조 형식이다. 그러면 ref 없이도 원본이 바뀔까.
절반만 맞다.
1
2
3
4
5
6
7
8
9
10
11
12
class Player { public int Hp; }
static void Damage(Player p)
{
p.Hp -= 10; // 반영된다
}
static void Replace(Player p)
{
p = new Player(); // 반영되지 않는다
p.Hp = 999;
}
1
2
3
4
5
6
7
Player player = new Player { Hp = 100 };
Damage(player);
Console.WriteLine(player.Hp); // 90. 바뀌었다
Replace(player);
Console.WriteLine(player.Hp); // 90. 안 바뀌었다
이유는 이렇다. 클래스 변수에 들어 있는 건 객체가 아니라 객체의 주소다. 함수에 넘어가는 건 그 주소의 복사본이다.
- 주소를 따라가서 내용을 고치면(
p.Hp) 같은 객체를 가리키니 밖에서도 보인다 - 주소 자체를 바꾸면(
p = new) 복사본만 다른 곳을 가리킬 뿐 원본 변수는 그대로다
“참조 형식은 참조로 전달된다”가 아니라 “참조가 값으로 전달된다” 가 정확한 표현이다. 이걸 구분하고 나니 헷갈릴 일이 없어졌다.
변수가 가리키는 대상 자체를 바꿔야 하면 그때는 클래스여도 ref가 필요하다.
1
static void Replace(ref Player p) { p = new Player { Hp = 999 }; }
성능 때문에 쓰는 in
C# 7.2부터 in이 생겼다. 원본을 넘기되 함수 안에서 못 바꾸게 한다.
1
2
3
4
static double Length(in Vector3 v) // 복사 없이, 읽기 전용
{
return Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}
큰 구조체를 값으로 넘기면 통째로 복사된다. 매 프레임 수천 번 부르는 함수라면 이 비용이 쌓인다. in은 복사를 피하면서 수정도 막는다.
작은 구조체나 int 정도에는 의미가 없다. 오히려 간접 참조 때문에 느려질 수 있다.
정리하면
- 값 형식은 복사되어 전달된다. 함수 안에서 바꿔도 원본이 안 바뀐다
ref는 원본을 넘긴다. 교환처럼 값 두 개를 동시에 바꿔야 할 때 필요하다ref는 호출 전 초기화가 필요하고,out은 함수 안에서 반드시 대입해야 한다out은 값을 여러 개 돌려줄 때 쓰지만, 요즘은 튜플이 더 읽기 쉽다- 클래스는 참조가 값으로 전달된다. 멤버 수정은 반영되고 재할당은 반영되지 않는다
- 큰 구조체를 복사 없이 읽기만 하려면
in