포스트

(C#) 11. 코드의 흐름 제어 (ref, out)

값을 복사해 넘기는 기본 동작과 ref/out의 차이를 Swap과 Divide로 확인했다. 클래스를 ref 없이 넘겼을 때 멤버 수정은 반영되는데 재할당은 안 되는 이유까지 정리했다.

(C#) 11. 코드의 흐름 제어 (ref, out)

복사본이냐 원본이냐

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 의 차이

둘 다 원본을 넘기는데, 초기화 책임이 반대다.

 refout
호출 전 초기화필요하다필요 없다
함수 안에서 대입선택반드시 해야 한다
의미값을 주고받는다값을 받아온다
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
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.