개인적으로 유니티 다룰 때 가장 혼란스러웠던거.
1 2 3 4 | // Update는 프레임마다 실행됨 void Update() { this.transform.position = new Vector3(x_pos, y_pos, z_pos); } | cs |
대부분 컴퓨터들의 프레임이 60프레임이므로, 저 함수에 있는 new는 1초에 60번씩 호출된다. C++을 해본 적이 있는 사람이라면 "아니 미123친 동적할당을 초에 60번씩 한다고? 미123친건가?" 하는 소리가 절로 나올 것이다. 필자가 그랬다. C랑 C++해본 사람은 동적할당때문에(정확히는 망할 포인터 때문에) 고생한 경험이 있기 때문에...
설령 C++을 안했더라도 "클래스(?)를 저렇게 많이 생성해도 되는가"하는 의문은 들 것이다. 그래서 그걸 피하려고 아래와 같은 코드를 시도해 본 사람이 많을 것이다.
1 2 3 4 | // Update는 프레임마다 실행됨 void Update() { this.transform.position.Set(x_pos, y_pos, z_pos); } | cs |
문제는 이 코드는 작동을 안한다. position을 바꾸려면 new를 쓰는 수밖에 없다.
하지만 아래 예시같은 것들은 또 new를 안써도 되서 실로 혼란스럽기 짝이 없다.
1 2 3 4 5 | // Update는 프레임마다 실행됨 void Update() { this.transform.position = Vector3.forward; this.transform.position = other_object.transform.position; } | cs |
더 이상한점은 저게 동적할당이고, 진짜로 프레임단위로 동적할당을 하는 미친짓을 한다면, 메모리가 허구한날 피크나거나 아니면 가비지컬렉터가 끊임없이 돌아가서 미친듯이 느려져야 할 것인데, 정작 메모리 상태를 보면 아니라는 것이다.
더 웃긴건 만일 아래와 같이 new를 한번만 하도록 따로 캐시용으로 변수를 만들어서 쓴다면, 위 new쓰는 코드보다 메모리를 더 많이 잡아먹는다는 것이다.
1 2 3 4 5 6 7 | Vector3 some_pos = new Vector(0,0,0) // Update는 프레임마다 실행됨 void Update() { some_pos.Set(pos_x, pos_y, pos_z); this.transform.position = some_pos; } | cs |
위와 같은 이상한 현상이 나타나는 것은 아래 3가지 이유로 요약 가능하다.
1. Vector3는 클래스가 아니고 구조체다.
2. C#에서 구조체에 대해 쓰는 "new"는 동적할당이 아니고 구조체 초기화다. (클래스에 대해 사용하면 동적할당 맞음)
3. C#에서 구조체는 값으로 주고받으며(Call by Value, Return by Value), 클래스는 참조자로 주고받는다(Call by Reference, Return by Reference).
C# 배웠으면 저것들 당연히 아는거 아니냐고 물어볼 수도 있다.
- 하지만 프로그래밍 처음 배운 입장에서 C#를 막 시작한 사람이라면 Call by Value랑 Call by Reference가 뭔지도 모를 가능성이 높다.
또한 다른 프로그래밍을 하다 왔다면 (특히 JAVA, C++), 배운 언어에 따라 달라지는데,
- C++배운 사람은 "와! 구조체! 여기도 있구나!"하고 대충 넘겨버릴 가능성이 있다.
- JAVA배우고 온 사람은 "이 클래스 하위호환버전같은 개불편한거는 뭐임"하고 대충 넘겨버릴 수 있다. 여담으로 JAVA에는 구조체가 없다.
(여담 : 역사적으로 구조체가 더 근본이다)
필자는 위 두 케이스 모두에 해당되었다. JAVA먼저 배우고 C, C++ 한 다음에 유니티 할 때 C#은 대충 공부해도 되겠거니 하면서 기본서 절반만 읽고 덮었다가 몇 달간 저거때매 고생했다.
아무튼 각설하고, 저 위에 있는 애들을 더 설명해보자.
1. Vector3는 클래스가 아니고 구조체다.
C, C++ 배운 사람들은 여기서부터 혼란을 겪을 가능성이 높다. "아니 Vector3.Normalize(), Vector3.Magnitude()같은 함수는 그럼 뭐냐, 얘들 클래스 아니냐?" 하면서 말이다. 왜냐하면 C/C++에서 구조체와 클래스를 배울 때 아래와 같이 설명하기 때문이다.
1) 옛날 옛적에 C에서는 자료형 여러개를 묶어서 담을 용도로 있던 구조체가 살고 있었어오
2) 하지만 사용자가 구조체의 모든 값을 초기화해야 쓸 수 있어서 겁내 불편했고, 특히 함수를 집어넣으려면 함수의 역참조 포인터를 위한 공간을 마련해서 초기화할 때 구조체 바깥에 있는 함수를 끌고 와서 초기화하는 겁내 귀찮은 짓거리를 했어야 했어오
3) 따라서 C++에서 초기화도 자동으로 해주고 함수도 편하게 안에 넣어서 캡슐화도 편하게 해주는 클래스가 등장했답니다.
라고 배웠기 때문에 이들의 머릿속에서는 "구조체는 그 안의 모든 내용물을 초기화해야 한다", "구조체에는 함수를 클래스마냥 (초기화 안 하고)넣을 수 없다" 라는 고정관념이 있다.
문제는 C#에서는 구조체 안에 함수(정확히는 메서드)를 넣을 수 있다. 그것도 JAVA에서 추상메소드로 델리게이트니 뭐니 하는식으로 넘겨서 초기화하거나, 역참조 포인터로 구조체 바깥에서 끌고 올 필요 없이, 그냥 구조체 안에서 함수를 넣고, 그걸 클래스마냥 쓸 수 있다.
믿기지 않는다면 아래 주소에 Vector3 소스코드가 나와있으니, 구조체 안에 메서드가 있는 걸 보고 오면 된다.
https://github.com/Unity-Technologies/UnityCsReference/blob/master/Runtime/Export/Math/Vector3.cs
여담으로 구조체 안에 메서드를 넣을 수 있다는거는 C#을 주력 언어로 한다고 하는 필자의 컴공과 친구도 몰랐었다. 아마 필자와 필자 친구같은 사람이 한둘이 아닐 것이다.
따라서 Normalize()같은 함수를 초기화안해도 쓸수 있는 Vector3는 클래스가 아니고 구조체다. 불만이라면 C# 만든 마이크로소프트에 따지십시오 휴먼.
2. C#에서 구조체에 대해 쓰는 "new"는 힙에 넣는 동적할당이 아니고 구조체 초기화다. (클래스에 대해 사용하면 동적할당 맞음)
제곧내.
https://learn.microsoft.com/ko-kr/dotnet/csharp/language-reference/operators/new-operator
마치 C++에서 한번에 구조체를 초기화할 때, some_struct a = {1, 2, "some_string", &some_ptr} 하는 걸 C#에서는
1 2 3 | void Update() { some_struct a = new some_struct(1, 2, "some_string"); } | cs |
같이 한다는 것이다.
여기서 질문이 두 개 정도 나올 수 있다.
"아니 그럼 동적할당이랑 헷갈리게 new를 왜 씀?" 이라고 물어볼 수도 있다.
그러게요. 시123발. 왜 사람 헷갈리게 이따구로 만들었는지 필자도 물어보고 싶다.
C++에서 동적할당에 시달린 트라우마가 있는 사람은 이렇게도 물어볼 것이다.
"그럼 저렇게 초기화하면 동적할당으로 힙에 저장됨? 아니면 스택에 저장됨?"
그냥 저건 C언어에서 some_struct a = {1, 2, "some_string" }; 한 거랑 똑같다고 보면 된다. 그러니까 놀랍게도 동적할당 아니고, 따라서 힙이 아니고 스택에 저장된다. 그리고 함수 끝나면(Update같은거) 자동으로 소멸된다.
그러니까 C#에서는 구조체 자체를 동적할당 해버릴 방법은 없다. 클래스 안에 넣어버리면 모를까.
근데 자료구조 쓰는거 아니면 구조체를 굳이 클래스 안에 넣어버리느니 차라리 그냥 클래스로 쓰지 보통. 함수 말고 다른 변수는 초기화해야 하는데 솔직히 귀찮고.
여기까지 new를 쓰는 이유에 대해 알았다. 이제 Set이 안되는 이유를 설명해보자.
3. C#에서 구조체는 값으로 주고받으며(Call by Value, Return by Value), 클래스는 참조자로 주고받는다(Call by Reference, Return by Reference).
이 문단은 읽는 사람이 Call by Value와 Call by Reference의 구분을 알고 있다는 전제 하에 쓰여졌다. 만일 이쪽 부분이 하나도 이해되지 않는다면, 그것은 당신이 C, C++을 해야 한다는 뜻이다. 어차피 이 글 읽고 있다면 프로그래밍 해봤다는 의미일테니 아래 두 책을 추천한다. (프로그래밍 완전 처음이면 살짝 부담될 수 있지만, 알고 있다면 메모리 구조를 빡세게 배우는 좋은 기회가 될 거시다)
- 독하게 시작하는 C 프로그래밍(최호성)
- 이것이 C++이다 (최호성)
JAVA같은 언어에서 클래스는 인자로 넣을 때나 반환될 때나 Reference다. (C++은 & 안적으면 그냥 Call by Value로 클래스를 값으로 복사해서 넘기는 미친짓을 하게 된다.)
따라서 아래의 코드에서처럼,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | public class SomeClass() { public int num = 0; } void Inc(int arg) { arg++; } void Inc(SomeClass arg) { arg.num++; } int a = 0; SomeClass b = new Someclass; Inc(a); // a == 0 Inc(b); // b == 1 | cs |
같이 했을 때, 클래스인 b의 경우 값이 바뀌지만, 그냥 정수형인 a는 값이 바뀌지 않는다.
이는 a의 경우, 함수로 인자를 넘겨줄 때, a와는 별개로 a의 복사본이 생성되고 함수로 전달되어서 그렇다. (Call by Value) 따라서 본래의 a의 영향을 미치지 못한다. 따라서 인자를 Call by Value로밖에 넘기지 못하는 C에서는 포인터를 사용해 Call by Reference마냥 본래 변수를 바꾼다.
C++도 클래스든 뭐든 기본값은 전부 값으로 넘겨버리기에, C++에서는 &기호를 사용해 참조자로 넘길 수 있다. 물론 포인터도 그대로 사용 가능하고,
JAVA에서는 클래스는 인자로 주고, 반환될 때 전부 참조자이므로 원본 값을 바꿔야 할 때 클래스로 감싸서 보내기도 하는데, 이를 박싱/언박싱이라고 한다.
여기서 C#은 매우 특이한 언어 설계를 가진다. C#에는 C++의 &와 똑같은 역할을 하는 ref라는 키워드가 존재하면서, 동시에 클래스는 JAVA처럼 ref 선언 안해도 참조자로 받는다.
그럼 구조체는? 클래스가 아니라서 무조건 값으로 받는다. (ref 키워드 선언한 경우 제외)
여기 Transform의 원본 소스코드가 있으니 같이 봐보자:

저기 있는 get; set; 이런 애들은 자동구현 프로퍼티라고 부른다. 자세한 설명은 아래를 참고:
https://dobby-the-house-elf.tistory.com/298
저걸 좀 더 설명하기 쉽게 변형시키면
1 2 3 4 | public Vector3 char_pos; // 구조체다 public Vector3 position() { return char_pos; // 구조체라서 원본 char_pos가 아니고 복사본이 대신 전달됨. } | cs |
이다. (설명을 위해 Setter부분은 제외)
그리고 우린 저기서 ~.position().Set(~,~,~); 같은 바보짓을 하는 것이다.
복사본에 Set을 하는데 원본이 바뀔 리가 없지.
return ref를 했다면 모를까, 자동구현 프로퍼티라 ref같은거 안 만들어준다.
뭔가 잘못된 내용 있으면 누가 정정좀 부탁.