Vector3 를 함수에 넘겨 고쳤는데 원래 값이 그대로인 적이 있나요? C# 의 타입은 값 타입과 참조 타입으로 나뉘고, 넘기는 방식이 다릅니다. 이번 편에서는 그 차이에서 시작해, 값 타입이 object 로 바뀌는 박싱과, 값 타입에 null 을 담는 Nullable 까지 봅니다.
2024년 부산 콘텐츠 아카데미 퍼스널 트레이닝 수업에서 MyLesson Unity 실습 프로젝트의 Strategy_04_Nullable.cs 로 가르친 내용입니다. 수업은 다섯 단계로 나뉘고, 단계마다 코드가 조금씩 바뀝니다.
고쳤는데 그대로인 Vector3
public void Func(Vector3 pos) // call by value
{
pos.x = 1000;
}
public void Func(MyClass pos) // call by reference
{
pos.x = 1000;
}Vector3 ppp = new Vector3(1, 1, 1);
Func(ppp);
Program.WriteLine(ppp);
Program.WriteLine(ppp.GetType());
object objPPP = ppp;
objPPP.GetType();
MyClass mc = new MyClass(1, 1, 1);
Func(mc);
Program.WriteLine(mc);
Program.WriteLine(mc.GetType());(1.00, 1.00, 1.00)
UnityEngine.Vector3
(1000, 1, 1)
Strategy_04_Nullable+MyClass두 Func 는 똑같이 pos.x = 1000 을 합니다. 그런데 Vector3 는 그대로이고, MyClass 는 바뀌었습니다.
| 값 타입 (struct) | 참조 타입 (class) | |
|---|---|---|
| 예 | int, float, bool, Vector3, DateTime | string, 배열, List, MyClass, GameObject |
| 변수에 든 것 | 값 그 자체 | 객체가 있는 곳(참조) |
| 함수에 넘기면 | 값이 복사되어 넘어간다. 함수 안에서 고친 것은 복사본 | 참조가 복사되어 넘어간다. 같은 객체를 가리키므로 고치면 원본이 바뀐다 |
| 기본값 | 0 으로 채운 값 | null |
코드 주석에는 class 쪽을 call by reference 라고 적었습니다. 엄밀히는 "참조를 값으로 넘긴다" 가 맞습니다. 함수 안에서 pos = new MyClass(…) 처럼 변수 자체를 바꾸면 원본 변수는 바뀌지 않습니다. 변수 자체를 넘기려면 ref 키워드를 씁니다.
박싱과 언박싱
값 타입을 object 자리에 넣으면, C# 은 값을 상자(box)에 담아 힙에 올리고 그 상자의 참조를 넘깁니다. 이것이 박싱입니다. 상자에서 값을 꺼내 다시 값 타입으로 되돌리는 것이 언박싱입니다.
private void DoBoxingUnBoxing(params object[] args)
{
Program.WriteLine("DoBoxingUnBoxing의 파라메터로 넘어올 때 박싱(값 타입 >>> 참조 타입)됩니다.");
for (int i = 0; i < args.Length; i++)
{
if (args[i] is int)
{
int n = (int)args[i];
Program.WriteLine("Typecast 코드로 인해 언박싱(참조 타입 >>> 값 타입)됩니다.");
Program.WriteLine(n + "is int");
}
else if (args[i] is char)
{
char c = (char)args[i];
Program.WriteLine("Typecast 코드로 인해 언박싱(참조 타입 >>> 값 타입)됩니다.");
Program.WriteLine("'" + c + "' is char");
}
}
}
public void Run()
{
DoBoxingUnBoxing(10, 'D');- 10, 'D'값 타입
- params object[] args박싱: 상자에 담김
- args[i] is int상자 안의 타입 확인
- (int)args[i]언박싱: 값으로 꺼냄
params object[] 는 인자를 몇 개든 받아 object 배열로 묶습니다. 그래서 10 과 'D' 가 넘어오는 순간 박싱이 일어납니다. 박싱은 힙에 상자를 만드는 일이라 매 프레임 많이 일어나면 쓰레기(GC)가 쌓입니다. 2편의 제네릭이 반가운 이유이기도 합니다.
ArrayList 에 int 를 Add 하면 매번 박싱됩니다. List<int> 는 int 를 그대로 담아 박싱이 없습니다.
Nullable<int>, null 을 담는 값 타입
int 는 값 타입이라 null 이 없습니다. 하지만 "아직 정하지 않음" 을 표현하고 싶을 때가 있습니다. 데이터베이스의 빈 칸 같은 경우입니다. 그래서 값에 "있음 · 없음" 표시를 붙인 Nullable<int> 가 있습니다.
public void Run()
{
Nullable<int> noValue = new Nullable<int>();
object noValueBoxed = noValue;
Program.WriteLine("noValueBoxed == null: {0}", noValueBoxed == null);
Nullable<int> someValue = new Nullable<int>(5);
object someValueBoxed = someValue;
Program.WriteLine("someValueBoxed == null: {0}", someValueBoxed == null);
Program.WriteLine("type of someValueBoxed: {0}", someValueBoxed.GetType());
Program.WriteLine("type of int: {0}", typeof(int));
}noValueBoxed == null: True
someValueBoxed == null: False
type of someValueBoxed: System.Int32
type of int: System.Int32Nullable 은 박싱할 때 특별하게 행동합니다. 값이 없으면 상자를 만들지 않고 그냥 null 이 되고, 값이 있으면 안의 int 만 상자에 담습니다. 그래서 박싱된 someValue 의 타입을 물으면 Nullable 이 아니라 System.Int32 라고 답합니다.
Nullable<int> noValue = new Nullable<int>();
//Program.WriteLine(noValue.GetType());
Program.WriteLine("noValue is int: <b>{0}</b>", noValue is int);
Program.WriteLine("noValue is Nullable<int>: <b>{0}</b>", noValue is Nullable<int>);
Program.WriteLine("noValue == null: <b>{0}</b>", noValue == null);
Program.WriteLine();
Nullable<int> someValue = new Nullable<int>(5);
Program.WriteLine(someValue.GetType());
Program.WriteLine(someValue);noValue is int: False
noValue is Nullable<int>: False
noValue == null: True
System.Int32
5값이 없는 Nullable 은 is 검사에서 무엇과 비교해도 false 입니다. is 는 null 에 대해 늘 false 이기 때문입니다. 있는지 확인할 때는 == null 이나 다음 단계의 HasValue 를 씁니다.
int? 는 Nullable<int> 의 줄임말
int? noValue = new int?();
Program.WriteLine("noValue is int: <b>{0}</b>", noValue is int);
Program.WriteLine("noValue is Nullable<int>: <b>{0}</b>", noValue is Nullable<int>);
Program.WriteLine("noValue.HasValue: <b>{0}</b>", noValue.HasValue);
Program.WriteLine("noValue == null: <b>{0}</b>", noValue == null);
Program.WriteLine();
int? someValue = new int?(5);
Program.WriteLine(someValue.GetType());
Program.WriteLine(someValue);
someValue++;
Program.WriteLine(someValue);noValue is int: False
noValue is Nullable<int>: False
noValue.HasValue: False
noValue == null: True
System.Int32
5
6int? 는 Nullable<int> 와 같은 타입입니다. 실제 코드에서는 거의 이 줄임말을 씁니다. ++ 같은 연산도 그대로 됩니다. 값이 없을 때 연산하면 결과도 null 입니다.
HasValue
값이 있으면 true
Value
값을 꺼냄. 값이 없으면 예외
GetValueOrDefault()
값이 없으면 0(또는 지정한 값)
x ?? 10
x 가 null 이면 10
as 로 안전하게 꺼내기
private void PringAsInt(object obj)
{
int? castedInt = obj as int?;
Program.WriteLine(castedInt != null ? castedInt.Value : "null");
}
public void Run()
{
PringAsInt(77);
PringAsInt("50");
PringAsInt(555.55f);
PringAsInt(90m);77
null
null
null(int)obj 처럼 캐스팅하면 obj 가 int 가 아닐 때 예외가 납니다. obj as int? 는 꺼낼 수 있으면 값을, 아니면 null 을 줍니다. as 는 null 이 될 수 있는 타입에만 쓸 수 있어서 as int 는 안 되고 as int? 로 씁니다. "50" 은 문자열이고, 555.55f 는 float, 90m 은 decimal 이라 int 로 꺼낼 수 없어 null 이 나옵니다.
if (obj is int n) { … } 처럼 쓰면 확인과 꺼내기를 한 줄에 할 수 있습니다. 1편의 instance is Strategy myClassInstance 가 이 모양입니다.
돌아보며
이번 편에서 얻은 것
- 값 타입은 값이, 참조 타입은 참조가 복사되어 넘어간다
- 값 타입이 object 자리에 가면 박싱, 꺼내면 언박싱
- int? 로 값 타입에 null 을 담고, as int? 로 안전하게 꺼낸다
다음에 풀 질문
- 함수 자체를 변수에 담아 넘길 수 있을까
- 버튼을 눌렀을 때 부를 함수를 어떻게 넘기나
정리
- 넘기는 것이 다릅니다. 값 타입은 값의 복사본이, 참조 타입은 같은 객체를 가리키는 참조가 넘어갑니다.
- 박싱은 공짜가 아닙니다. 값 타입을 object 로 다루면 상자가 힙에 생깁니다. 제네릭은 이 비용을 없앱니다.
- int? 는 값에 있음 표시를 붙인 것입니다. HasValue 로 확인하고, as int? 로 실패해도 예외 없이 꺼냅니다.
다음 편에서는 버튼을 눌렀을 때 실행할 함수를 넘기는 방법, 델리게이트와 람다를 봅니다.