C# 기초부터 기억해내자
이번 글에서는 아래 내용을 정리한다.
- C++에는 있는데 C#에는 없는 것들 (헤더,
delete, 포인터,#define,const멤버 함수) #define으로 하던 일을 C#에서는 어떻게 하는가class와struct의 차이, 그리고transform.position.x = 1이 왜 컴파일 에러인가- 프로퍼티가 도대체 무엇인가
- null 참조는 C#에서 어떻게 되는가
- 제네릭,
foreach, 캐스트 - delegate(
Action,Func)와 람다식, 그리고 캡처 event, 속성(Attribute), LINQ- 마지막으로 2022년 학원 시절에 내가 짠 코드를 다시 읽어 본다
들어가기에 앞서
나는 2022년에 학원에서 8개월 동안 유니티를 배웠다. 그리고 그 뒤로 만 4년 동안 MMORPG 서버 개발자로 일했다. 쓰는 언어는 C++, 그것도 C++03이다.
그러다 다시 유니티를 만져 보려고 에디터를 켰는데, 아무것도 기억이 나지 않았다. 직렬화 필드가 뭔가 있었고, MonoBehaviour가 아주 중요했고, 프리팹으로 뭔가를 만들었다는 파편만 남아 있었다. 더 큰 문제는 C#이었다. 4년 동안 C++만 썼더니 C# 문법이 통째로 날아가 있었다.
그래서 C#부터 다시 기억해내기로 했다. 방법은 간단하다. 내가 아는 C++ 코드를 옆에 놓고, 같은 일을 C#으로는 어떻게 쓰는지 하나씩 비교한다. 이 글은 그 과정에서 궁금했던 질문과 찾은 답을 순서대로 정리한 것이다.
유니티가 지원하는 C#은 9.0까지다. 그리고 내 C++은 C++03이라서 람다나 auto 같은 것은 써 본 적이 없다. 모던 C++을 쓰는 사람이라면 중간중간 “이건 C++11에도 있는데?” 싶은 부분이 나올 텐데, 나에게는 전부 처음 보는 개념이었다.
C++에서 C#으로 오면 없어지는 것들
새로 배울 것보다 잊어도 되는 것이 더 많다. 좋은 소식부터 적는다.
헤더 파일이 없다
C++에서는 클래스 하나를 만들려면 .h에 선언을 쓰고 .cpp에 구현을 쓰고, 쓰는 쪽에서 #include를 했다. 순환 참조가 생기면 전방 선언도 해야 했다.
C#에는 이것이 전부 없다. 파일 하나에 클래스 전체를 쓴다.
using UnityEngine; // #include 대신 네임스페이스를 가져온다
public class Player : MonoBehaviour
{
int hp = 100;
void Update()
{
Helper(); // 아래에 선언된 함수를 위에서 불러도 된다. 선언 순서는 상관없다
}
void Helper() { }
}같은 프로젝트 안에 있는 클래스는 #include 없이 그냥 보인다.
delete가 없다
// C++
Player* p = new Player;
delete p;// C#
Player p = new Player();
// 끝. delete는 없다아무도 쓰지 않게 된 객체는 GC(가비지 컬렉터)가 알아서 회수한다. 대신 소멸자가 언제 불릴지는 알 수 없다. 유니티 오브젝트는 Destroy(obj)로 없앤다.
포인터 기호가 없다
*, &, ->가 전부 없어지고 멤버 접근은 무조건 .이다. 그러면 참조 전달은 어떻게 할까? 이 부분은 조금 뒤에 class와 struct에서 같이 정리한다.
멤버마다 접근 제한자를 붙인다
C++은 public: 아래에 있는 것이 전부 public이었다. C#은 멤버 하나하나에 붙인다. 안 붙이면 private이다.
public class Player
{
public int level; // public
private int hp; // private
int mp; // 안 붙였으니 private
}#define은 어디로 갔는가
나는 #define을 정말 많이 썼다. 상수도 #define, 배열 크기도 #define, 이벤트 on/off도 #define이었다. 그런데 C#에는 값을 치환하는 #define이 없다.
상수: #define EXPBONUS 5.0
// C++ : define.h
#define EXPBONUS 5.0
#define MAX_LEVEL 100
exp = baseExp * EXPBONUS;C#에서는 **상수만 모아 둔 static class**를 하나 만든다. 이 파일이 define.h 역할을 한다.
// GameConst.cs
public static class GameConst
{
public const float ExpBonus = 5.0f;
public const int MaxLevel = 100;
}
// 아무 파일에서
exp = baseExp * GameConst.ExpBonus;static class는 객체를 만들 수 없는 클래스다. 전역 상수나 유틸 함수를 담는 통이라고 보면 된다.
5.0f의 f를 빼먹으면 컴파일 에러가 난다. C#에서 5.0은 double이고, double을 float에 넣는 것을 허용하지 않기 때문이다. C++보다 깐깐하다.
const는 숫자나 문자열처럼 컴파일할 때 값이 정해지는 것만 된다. new가 필요한 값은 static readonly를 쓴다.
public static class GameConst
{
public const float MaxSpeed = 600f;
public static readonly Vector2 StartPos = new Vector2(0, 5); // const로는 못 만든다
}매크로와 달라지는 점이 하나 있다. #define은 텍스트 치환이라 타입이 없었지만, const float는 컴파일러가 타입을 검사해 준다.
배열 크기: #define LIMIT 10, int a[LIMIT];
// C++
#define LIMIT 10
int a[LIMIT];
for (int i = 0; i < LIMIT; ++i) a[i] = 0;// C#
int[] a = new int[GameConst.Limit];
for (int i = 0; i < a.Length; i++) a[i] = 0;여기서 궁금증이 생겼다. C#에는 int a[10]; 같은 고정 배열 선언이 없는 걸까?
없다. C#의 배열은 전부 new로 힙에 만든다.
- 타입에 크기가 없다.
int[10]이라는 타입은 없고int[]뿐이다. 크기는 배열 객체가 들고 있고a.Length로 읽는다. 그래서 함수에 넘길 때 크기를 따로 넘길 필요가 없다. - 만든 뒤에는 크기를 바꿀 수 없다. 이 점은 C++ 배열과 같다. 늘어나야 하면
List<T>(C++의std::vector)를 쓴다. - 크기가 컴파일 상수일 필요도 없다.
new int[n]처럼 변수로 줘도 된다.
C++의 멤버 배열 int m_slots[10];에 해당하는 것은 보통 이렇게 쓴다.
public class Inventory
{
const int SlotCount = 10;
readonly int[] slots = new int[SlotCount];
}전처리기 스위치: #ifdef EVENT_A
서버에서 이런 식으로 빌드를 관리했다.
// C++
#define EVENT_A
#ifdef EVENT_A
GiveEventReward();
#else
GiveNormalReward();
#endif이것은 C#에도 그대로 있다. 값 치환용 #define만 없고, 심볼을 켜고 끄는 것은 된다.
#if EVENT_A
GiveEventReward();
#else
GiveNormalReward();
#endif
#if EVENT_A && !UNITY_EDITOR // 조합도 된다
#endif다른 점은 심볼을 정의하는 위치다. C#의 #define은 그 파일 안에서만 유효하다. 헤더에 적어 전체에 뿌리던 방식이 안 된다. 유니티에서는 프로젝트 설정에 적는다.
Edit > Project Settings > Player > Other Settings > Scripting Define Symbols
여기에 EVENT_A를 추가하면 모든 스크립트에 적용된다. 유니티가 미리 정의해 둔 심볼도 있다. UNITY_EDITOR, UNITY_ANDROID, UNITY_IOS, DEVELOPMENT_BUILD 같은 것들이다.
매크로 함수로 “릴리스에서는 로그 호출 자체를 지운다"를 하던 것은 [Conditional]로 한다.
using System.Diagnostics;
public static class Log
{
[Conditional("ENABLE_LOG")]
public static void Info(string msg) { UnityEngine.Debug.Log(msg); }
}
Log.Info("테스트"); // ENABLE_LOG 심볼이 없으면 이 호출 줄 자체가 컴파일에서 빠진다그런데 여기서 생각할 것이 하나 있다. 전처리기 스위치는 빌드를 다시 해야 바뀐다. 서버는 빌드해서 올리면 끝이지만, 클라이언트는 빌드를 바꾸면 스토어 심사를 다시 받아야 한다. 그래서 게임 클라이언트에서는 이벤트 on/off나 밸런스 값을 전처리기가 아니라 데이터로 빼고, 플랫폼 차이나 디버그 코드, 치트 메뉴 정도만 전처리기로 남긴다. 서버만 해 온 나로서는 한 번도 생각해 본 적 없는 관점이었다.
const 멤버 함수와 const 참조가 없다
// C++
class Player
{
int m_hp;
public:
int GetHp() const { return m_hp; } // 이 함수는 멤버를 안 바꾼다는 약속
};
void Print(const Player& p) // 복사 없이 넘기되 수정은 금지
{
p.GetHp(); // OK
// p.SetHp(0); // 컴파일 에러
}C#에는 이 두 가지가 없다. “이 함수 안에서는 수정 금지"를 컴파일러가 보장해 주지 않는다.
대신 readonly라는 것이 있다. 필드에 붙이고, 생성자에서 딱 한 번만 대입할 수 있다는 뜻이다.
public class Wallet
{
readonly int maxMoney; // C++의 const int m_maxMoney;
readonly List<int> history = new List<int>();
public Wallet(int max) { maxMoney = max; } // 생성자에서는 대입 가능
void Test()
{
// maxMoney = 10; // 컴파일 에러
history.Add(1); // 이건 된다!
// history = new List<int>(); // 이건 컴파일 에러
}
}꼭 기억할 것 ①
readonly는 C++의const가 아니다나는 이름만 보고
readonly를const와 같은 것으로 생각했다. 틀렸다.readonly List는 C++로 치면List* const(포인터 고정)이지const List*(내용 고정)가 아니다. 리스트 변수를 다른 리스트로 바꿔 끼우지 못할 뿐, 내용은 얼마든지 바꿀 수 있다. “readonly니까 안 바뀌겠지” 하고 믿으면 안 된다.
class와 struct는 완전히 다른 물건이다
꼭 기억할 것 ② C#의
struct는 C++의struct가 아니다C++에서
class와struct는 기본 접근 제한자만 다를 뿐 사실상 같다. 나는 C#도 당연히 그럴 것이라고 생각했다. 틀렸다. C#에서는 둘이 전혀 다른 물건이고, 어느 쪽으로 선언하느냐에 따라 복사되느냐 공유되느냐가 갈린다.
class는 참조 타입이다. 변수는 포인터처럼 객체를 가리킨다.struct는 값 타입이다. 대입하거나 함수에 넘기면 복사된다.
struct Stat { public int hp; }
class Actor { public int hp; }
void Hit(Actor a) { a.hp = 0; } // 원본이 바뀐다. C++의 Actor* 로 넘긴 것과 같다
void Hit(Stat s) { s.hp = 0; } // 복사본만 바뀐다. C++의 값 전달과 같다
void Hit(ref Stat s) { s.hp = 0; } // 원본이 바뀐다. C++의 Stat& 와 같다
Stat st = new Stat { hp = 10 };
Hit(st); // st.hp는 여전히 10
Hit(ref st); // st.hp는 0. 호출하는 쪽에도 ref를 적는다| C++03 | C# |
|---|---|
void f(Stat s) 값 전달 |
void f(Stat s). struct이면 복사 |
void f(Stat& s) 참조 전달 |
void f(ref Stat s) |
void f(Actor* a) 포인터 전달 |
void f(Actor a). class이면 자동으로 이것 |
void f(int* arr, int n) |
void f(int[] arr). 크기는 arr.Length |
C++에서는 넘기는 쪽 문법(&, *)이 복사 여부를 정했다. C#에서는 타입을 선언할 때 class냐 struct냐로 정해진다. 아까 미뤄 둔 “포인터 기호가 없으면 참조 전달은 어떻게 하나"의 답이 이것이다. class는 원래 참조로 넘어가고, struct를 참조로 넘기고 싶을 때만 ref를 쓴다.
transform.position.x = 1;은 왜 컴파일 에러일까?
유니티를 하면 한 번은 만나게 되는 에러다.
transform.position.x = 1; // 컴파일 에러 CS1612나는 이렇게 추측했다. “Vector3가 struct라서 값 복사가 일어나고, 그러면 원본이 안 바뀌니까 컴파일러가 에러를 띄우는 것 아닐까?”
방향은 맞았다. 그런데 정확히는 “struct라서"가 아니라 **“struct를 프로퍼티로 꺼냈기 때문”**이다. 그러면 프로퍼티가 무엇인지부터 알아야 한다.
프로퍼티는 변수처럼 생긴 함수다
꼭 기억할 것 ③ 프로퍼티는 변수가 아니라 함수다
나는 “프로퍼티로 꺼냈다"는 말을 getter 없이 변수를 그냥 꺼냈다는 뜻으로 이해했다. 정반대였다. getter 함수를 거쳐서 꺼냈다는 뜻이다.
obj.position처럼 괄호 없이 쓰여 있어도 그 뒤에서는 함수가 호출되고 있다.
C++로 먼저 보자.
class Transform
{
Vector3 m_pos;
public:
Vector3 GetPosition() { return m_pos; } // 값으로 반환 = 복사본
void SetPosition(Vector3 v) { m_pos = v; }
};
t.GetPosition().x = 1; // 임시 복사본의 x를 바꾼다. m_pos는 그대로. 의미 없는 코드
C#의 프로퍼티는 이 getter/setter를 이렇게 쓴다.
class Transform
{
Vector3 pos; // 진짜 변수 (필드)
public Vector3 position // 프로퍼티. 변수가 아니라 함수 두 개의 묶음이다
{
get { return pos; } // GetPosition()에 해당
set { pos = value; } // SetPosition(v)에 해당. value가 넘어온 값이다
}
}쓰는 쪽에서는 괄호가 없어서 변수처럼 보인다.
Vector3 a = t.position; // 실제로는 get 호출 → GetPosition()
t.position = a; // 실제로는 set 호출 → SetPosition(a)
t.position.x = 1; // 실제로는 GetPosition().x = 1 → 복사본을 고친다 → 에러이제 이해가 된다. transform.position이라고 쓰는 순간 눈에 보이지 않는 getter 함수가 호출되고, Vector3가 struct이니 복사본이 나온다. 거기에 .x = 1을 해 봐야 임시 복사본만 바뀌고 버려진다. 컴파일러가 이 무의미한 코드를 막아 주는 것이다.
그래서 유니티 코드에는 “꺼내서, 고치고, 다시 넣기” 세 줄이 자주 나온다.
Vector3 p = transform.position; // getter로 복사본을 받는다
p.x = 1; // 내 지역 변수를 고친다
transform.position = p; // setter로 통째로 넣는다같은 struct라도 프로퍼티가 아니라 필드(진짜 변수)이면 복사가 일어나지 않아서 된다.
| 필드 | 프로퍼티 | |
|---|---|---|
| 정체 | 진짜 변수 | getter/setter 함수 |
| 선언 | public Vector3 pos; |
public Vector3 pos { get; set; } |
| 쓰는 모양 | obj.pos |
obj.pos (똑같이 생겼다) |
struct일 때 obj.pos.x = 1 |
된다 | 컴파일 에러 |
쓰는 모양이 같다. 선언에 { get; ... } 중괄호가 있으면 프로퍼티다.
자주 보는 짧은 형태는 컴파일러가 숨은 필드와 getter/setter를 대신 만들어 주는 축약형이다.
public int Hp { get; private set; } // 밖에서는 읽기만, 클래스 안에서만 쓰기C++에서 m_hp를 private으로 두고 GetHp()만 public으로 열던 것을 한 줄로 쓴 것이다. 앞에서 “C#에는 const 멤버 함수가 없다"고 했는데, 밖에서 못 바꾸게 막는 일은 이 프로퍼티가 대신한다.
null 참조는 크래시 대신 예외가 된다
null 참조 자체는 C#에도 있고 C++만큼 흔한 버그다. 다른 점은 결과다.
// C++
Player* p = NULL;
p->hp = 10; // 접근 위반. 프로세스가 죽는다. 운이 나쁘면 안 죽고 엉뚱한 메모리를 망가뜨린다
// C#
Player p = null;
p.hp = 10; // NullReferenceException이 던져진다. 항상, 정확히 이 줄에서C#은 프로세스가 죽는 대신 예외가 던져진다. 파일 이름과 줄 번호가 담긴 스택 트레이스가 붙어 있어서 덤프를 뜰 필요가 없다.
그런데 유니티에서는 여기에 한 가지가 더 있다. 유니티가 Update 같은 콜백을 부르는 바깥에서 예외를 잡아 준다. 그래서 이렇게 된다.
- 예외가 난 그 함수의 나머지 부분만 실행되지 않는다.
- Console에 빨간 에러가 찍힌다.
- 게임은 계속 돈다.
void Update()
{
target.TakeDamage(1); // target이 null이면 여기서 예외
MoveForward(); // 이 줄은 실행되지 않는다. 매 프레임
}서버 개발자 입장에서는 이것이 오히려 무섭다. 서버는 죽으면 바로 안다. 그런데 유니티 게임은 예외를 내면서도 멀쩡히 도는 것처럼 보인다. 돈은 빠져나갔는데 아이템 지급 줄은 실행되지 않은 상태가 될 수 있다. 그래서 코드를 볼 때 Console의 에러가 0개인지부터 확인한다.
?.와 ??
null 검사를 짧게 쓰는 문법이다.
if (a != null) a.B(); → a?.B(); // null이면 아무것도 안 한다
x = (a != null) ? a : b; → x = a ?? b; // null이면 b를 쓴다그런데 유니티 오브젝트에는 쓰면 안 된다. 유니티 오브젝트는 C# 쪽 껍데기와 엔진(C++) 쪽 실제 객체의 두 겹으로 되어 있다. Destroy(obj)를 하면 엔진 쪽만 즉시 없어지고 C# 껍데기는 GC가 가져갈 때까지 남는다. 유니티는 == 연산자를 오버로드해서 이 “죽은 껍데기"를 null처럼 보이게 해 두었는데, ?.와 ??는 그 오버로드를 거치지 않는다.
if (enemy != null) enemy.Hit(); // 안전하다. 파괴된 것도 걸러진다
enemy?.Hit(); // 위험하다. 죽은 껍데기는 통과해서 예외가 난다C++로 치면 파괴된 유니티 오브젝트는 댕글링 포인터와 비슷한 상태다. == null 검사가 그것을 걸러 주는 안전장치인데 ?.는 그 안전장치를 우회한다.
템플릿은 제네릭이 되었다
// C++
std::vector<int> v;
template <typename T> class Pool { T* items; };// C#
List<int> v = new List<int>();
class Pool<T> { T[] items; }쓰는 모양은 거의 같다. 유니티에서는 이런 식으로 매일 보게 된다.
Rigidbody2D rb = GetComponent<Rigidbody2D>(); // "이 타입의 컴포넌트를 달라"
List<Enemy> enemies;
Dictionary<string, int> prices;가장 다른 점은 **제약(where)**이다. C++ 템플릿은 T에 아무 짓이나 해도 되고, 실제 타입을 넣어 찍어낼 때 컴파일되면 통과였다. C# 제네릭은 T에 대해 미리 약속된 것만 쓸 수 있다.
T Spawn<T>() where T : Enemy, new() // T는 Enemy이거나 그 자식이고, 기본 생성자가 있다
{
T e = new T(); // new()를 약속받았으므로 가능
e.TakeDamage(0); // Enemy임을 약속받았으므로 Enemy의 함수를 부를 수 있다
return e;
}그리고 템플릿 특수화가 없다. List<int>든 List<Enemy>든 같은 코드 하나로 돈다.
STL 컨테이너는 이렇게 대응된다.
| C++ | C# | 비고 |
|---|---|---|
std::vector<T> |
List<T> |
개수는 .Count |
| 배열 | T[] |
개수는 .Length |
std::map<K,V> |
Dictionary<K,V> |
해시라서 순서가 없다. 정렬이 필요하면 SortedDictionary |
std::set<T> |
HashSet<T> |
|
std::queue, std::stack |
Queue<T>, Stack<T> |
|
std::string |
string |
불변이다. 이어 붙일 때마다 새 객체가 생긴다 |
for와 foreach
for, while, do-while은 C++과 똑같이 있다. 거기에 C++03의 반복자 for 문을 대신하는 foreach가 하나 더 있다.
// C++03 : 반복자로 순회
for (std::vector<Enemy*>::iterator it = enemies.begin(); it != enemies.end(); ++it)
(*it)->Update();// C# : 같은 일을 하는 foreach
foreach (Enemy e in enemies)
e.Update();
// 인덱스 for 문도 그대로 된다
for (int i = 0; i < enemies.Count; i++)
enemies[i].Update();foreach는 내부적으로 반복자를 만들어 처음부터 끝까지 도는 문법이다. break와 continue도 된다.
조심할 것이 하나 있다. 순회 중에 그 컬렉션을 고치면 예외가 난다. C++에서 순회 중에 erase해서 반복자가 무효화되는 것과 같은 문제인데, C#은 UB 대신 예외로 바로 알려 준다.
foreach (Enemy e in enemies)
{
if (e.IsDead) enemies.Remove(e); // InvalidOperationException
}지우면서 돌아야 할 때는 인덱스 for 문을 뒤에서부터 돈다. C++ vector에서도 쓰던 방법이다.
for (int i = enemies.Count - 1; i >= 0; i--)
{
if (enemies[i].IsDead) enemies.RemoveAt(i);
}그러면 중간부터 돌거나, 두 칸씩 건너뛰거나, 특정 원소를 찾아 거기서부터 이어 가는 것은 어떻게 할까? C++에서는 반복자를 변수에 들고 다녔다. C#에서는 그 역할을 int 인덱스가 한다.
// C++
std::vector<int>::iterator it = std::find(v.begin(), v.end(), 42);
if (it != v.end()) v.erase(it);// C#
int idx = list.IndexOf(42); // 없으면 -1
if (idx >= 0) list.RemoveAt(idx);Dictionary는 인덱스가 없는 대신 키로 바로 찾는다.
if (prices.TryGetValue("lemon", out int price)) { } // std::map의 find에 해당캐스트는 네 가지다
class Enemy { }
class Boss : Enemy { public void Roar() { } }
Enemy e = GetEnemy(); // 실제로는 Boss일 수도, 아닐 수도 있다1. (T)x : 확신할 때. 틀리면 예외
Boss b = (Boss)e; // e가 Boss가 아니면 InvalidCastException모양은 C 스타일 캐스트와 같지만 런타임에 실제 타입을 검사한다. C++의 (Boss*)e는 틀려도 조용히 넘어가 UB가 되지만, C#은 그 줄에서 예외를 던진다.
2. x as T : 틀리면 null. C++의 dynamic_cast와 같다.
Boss b = e as Boss;
if (b != null) b.Roar();3. x is T t : 검사와 변환을 한 번에. 요즘은 이것을 가장 많이 쓴다.
if (e is Boss b) // e가 Boss이면 true이고, 변환된 결과가 b에 들어간다
{
b.Roar();
}4. 숫자 변환
float f = 3.7f;
int i = (int)f; // 3. 정보를 잃는 변환은 캐스트를 꼭 적어야 한다
float g = i; // 정보를 안 잃는 방향은 자동
int n = (int)State.Walk; // enum → int
bool ok = int.TryParse("abc", out int value); // 문자열 → 숫자는 캐스트가 아니라 함수다학원 시절의 내 버그
여기서 부끄러운 코드를 하나 꺼낸다. 2022년에 WinAPI로 게임을 만들 때 내가 짠 충돌 처리다.
CTile* pTile = (CTile*)pOtherObj; // 타일인지 확인하기 전에 캐스팅부터 했다
GROUP_TILE Type = pTile->GetGroup(); // 타일이 아니면 UB
if (pOtherObj->GetName() == L"Tile") { ... }타일이 아닌 오브젝트와 부딪혀도 일단 CTile*로 바꿔서 GetGroup()을 부르고 있다. 돌아가긴 했지만 순전히 운이었다. C#이라면 이렇게 쓴다.
if (other is Tile tile) // 타일일 때만 들어온다
{
GroupTile type = tile.Group;
}이름 문자열로 타입을 구분할 필요도 없고, 잘못된 캐스트가 조용히 넘어갈 일도 없다.
함수를 변수에 담기: delegate
Action, Action<int>, Func<int, bool>. 낯선 모양이지만 C++의 함수 포인터에서 출발하면 풀린다. **“함수를 변수에 담아 두었다가 나중에 부른다”**는 같은 개념이다.
// C++
void OnDamaged(int amount) { printf("%d\n", amount); }
void (*callback)(int); // "int를 받고 반환 없는 함수"를 가리키는 포인터 변수
callback = OnDamaged; // 함수를 담는다
callback(10); // 담긴 함수를 부른다
C++에서는 void (*)(int)처럼 타입을 매번 직접 적었다. C#은 자주 쓰는 모양에 미리 이름을 붙여 두었다.
| C# 타입 | 뜻 | C++로 쓰면 |
|---|---|---|
Action |
인자 없음, 반환 없음 | void (*)() |
Action<int> |
int 하나 받음, 반환 없음 | void (*)(int) |
Action<int, string> |
int와 string 받음, 반환 없음 | void (*)(int, string) |
Func<bool> |
인자 없음, bool 반환 | bool (*)() |
Func<int, bool> |
int 받음, bool 반환 | bool (*)(int) |
Func<int, int, float> |
int 둘 받음, float 반환 | float (*)(int, int) |
규칙은 두 가지다.
Action은 반환값이 없는 함수,Func는 반환값이 있는 함수다.Func는 꺾쇠 안의 마지막이 반환형이고 그 앞이 전부 인자다.
void OnDamaged(int amount) { Debug.Log(amount); }
Action<int> callback; // "int를 받고 반환 없는 함수"를 담는 변수
callback = OnDamaged;
callback(10);서버의 패킷 핸들러 테이블도 그대로 옮겨진다.
var handlers = new Dictionary<int, Action<Session, Packet>>();
handlers[PKT_LOGIN] = HandleLogin;
handlers[packet.Id](session, packet); // 조회해서 바로 호출이렇게 함수를 담는 타입을 통틀어 delegate라고 부른다. 함수 포인터보다 나은 점이 있다.
Action<int> cb = player.TakeDamage; // 멤버 함수도 그냥 담긴다. player 객체까지 같이 담긴다
Action<int> onChanged = null;
onChanged += UpdateMoneyText; // 여러 개를 담을 수 있다
onChanged += PlayCoinSound;
onChanged(100); // 둘 다 순서대로 불린다
onChanged -= PlayCoinSound; // 하나 제거정리하면 delegate는 함수 포인터의 모양이 바뀐 것이다.
람다식
이번 글에서 가장 오래 걸린 부분이다. C++03에는 람다가 없어서 문법이 아니라 개념 자체가 처음이었다.
(참고로 “람다"와 “람다식"은 같은 말이다. 정식 이름이 람다식이고 줄여서 람다라고 부른다.)
일반 함수에서 줄여 가기
// 시작: 이름 있는 함수
bool IsDead(Enemy e) { return e.Hp <= 0; }
enemies.RemoveAll(IsDead);
// 1단계: 이름을 빼고 그 자리에 쓴다. => 는 인자와 본문을 가르는 기호다
enemies.RemoveAll((Enemy e) => { return e.Hp <= 0; });
// 2단계: 인자 타입은 컴파일러가 안다 (List<Enemy>이니 e는 Enemy)
enemies.RemoveAll((e) => { return e.Hp <= 0; });
// 3단계: 본문이 식 하나면 중괄호와 return을 뺀다. 인자가 하나면 괄호도 뺀다
enemies.RemoveAll(e => e.Hp <= 0);네 가지 모두 같은 코드다.
숫자에 빗대면
int a = five; // 다른 곳에 선언해 둔 five를 넣는다
int b = 5; // 값을 그 자리에 바로 적는다. 5는 "정수 리터럴"이다delegate도 똑같다.
bool IsEven(int n) { return n % 2 == 0; }
Func<int, bool> f1 = IsEven; // 선언해 둔 함수를 넣는다
Func<int, bool> f2 = n => n % 2 == 0; // 함수를 그 자리에 바로 적는다. 이것이 람다식5가 정수 리터럴이듯 n => n % 2 == 0은 “함수 리터럴"이다.
n => n % 2 == 0
───┬─── ──┬── ──────┬──────
인자 "는" 반환값(본문)| 람다식 | 풀어 쓴 함수 |
|---|---|
n => n % 2 == 0 |
bool F(int n) { return n % 2 == 0; } |
(a, b) => a + b |
int F(int a, int b) { return a + b; } |
() => Debug.Log("hi") |
void F() { Debug.Log("hi"); } |
() => { x++; Save(); } |
void F() { x++; Save(); } |
처음에 읽기 어려웠던 가장 큰 이유는 타입이 안 보여서였다. 인자와 반환의 타입은 들어갈 delegate 타입을 보고 컴파일러가 채운다.
결국 나는 이렇게 이해했다.
람다식은 함수 포인터인데, 그 자리에서 함수를 만들고 그것을 함수 포인터로 넣은 것이다.
C++03이라면 조건 하나 때문에 함수 객체를 따로 만들어야 했다.
struct IsDead { bool operator()(Enemy* e) const { return e->hp <= 0; } };
enemies.erase(std::remove_if(enemies.begin(), enemies.end(), IsDead()), enemies.end());람다식은 이것을 한 줄로 그 자리에 쓴다.
그리고 하나 더. 람다식은 지금 실행되는 것이 아니라 넘겨지는 것이다.
button.onClick.AddListener(() => Buy(3));이 줄은 지금 Buy(3)을 부르는 것이 아니다. “나중에 클릭되면 Buy(3)을 부르는 함수"를 버튼에 건네는 것이다.
캡처
함수 포인터와 다른 점이 하나 있다. 람다식은 자기 바깥에 있는 변수를 같이 담아 갈 수 있다.
int limit = 50; // 람다식 바깥의 지역 변수
enemies.RemoveAll(e => e.Hp <= limit); // limit을 캡처했다C++03의 함수 포인터는 함수 주소만 들고 있어서 limit 같은 값을 실어 보낼 수 없었다. 그래서 콜백에 void* userData를 따로 붙이거나 멤버 변수를 가진 함수 객체를 만들었다. 람다식은 그 userData를 컴파일러가 자동으로 포장해 주는 것이다.
| C++03 | C# | |
|---|---|---|
| 함수 주소만 넘긴다 | 함수 포인터 | delegate에 함수 이름을 넣는다 |
| 그 자리에서 함수를 만들어 넘긴다 | 없음 | 람다식 |
| 함수와 데이터를 같이 넘긴다 | 함수 포인터 + void* userData |
캡처가 있는 람다식 |
문제는 그 포장에 힙 할당이 든다는 것이다. 캡처가 있는 람다식은 그 줄이 실행될 때마다 작은 객체를 new 한다. Start처럼 한 번 실행되는 곳이면 아무 문제가 없다. Update처럼 초당 60번 도는 곳이면 초당 60개의 쓰레기가 쌓이고, GC가 돌 때 프레임이 끊긴다.
// 피한다: 매 프레임 캡처 객체가 하나씩 생긴다
void Update()
{
Enemy target = enemies.Find(e => e.Hp < threshold);
}
// 고친 것: 그냥 for 문으로 쓴다. 할당이 없다
void Update()
{
Enemy target = null;
for (int i = 0; i < enemies.Count; i++)
if (enemies[i].Hp < threshold) { target = enemies[i]; break; }
}GC라는 것이 서버에는 없던 제약이라 낯설다. “매 프레임 도는 코드에서는 할당을 피한다"는 규칙은 앞으로도 계속 나온다.
event: 값이 바뀌면 알린다
public event Action<int> OnMoneyChanged;이것을 보고 “변화가 생기면 처리하려고 이런 식으로 짜는 건가?” 싶었는데 맞았다. 서버에서 쓰던 옵저버 패턴이다.
이벤트 없이 짜면 이렇게 된다.
public class Wallet
{
int money;
public MoneyText moneyText; // UI를 직접 알아야 한다
public UpgradeButton[] buttons;
public AudioSource coinSound;
public void Add(int amount)
{
money += amount;
moneyText.Refresh(money); // 돈에 반응하는 것을 전부 직접 호출
foreach (var b in buttons) b.Refresh(money);
coinSound.Play();
}
}돈에 반응하는 것이 하나 늘 때마다 Wallet을 고쳐야 한다. 이벤트로 짜면 이렇다.
// 로직: UI가 있는지조차 모른다
public class Wallet
{
public int Money { get; private set; }
public event Action<int> OnMoneyChanged;
public void Add(int amount)
{
Money += amount;
OnMoneyChanged?.Invoke(Money); // 구독자 전원에게 알린다
}
}// 화면: 관심 있는 쪽이 스스로 구독한다
public class MoneyText : MonoBehaviour
{
[SerializeField] TMP_Text label;
Wallet wallet;
void OnEnable() { wallet.OnMoneyChanged += Refresh; } // 구독
void OnDisable() { wallet.OnMoneyChanged -= Refresh; } // 해제
void Refresh(int money) { label.text = money.ToString(); }
}그러면 event라는 키워드는 왜 붙이는 걸까? 밖에서는 +=와 -=만 할 수 있게 막는 것이 역할이다. event 없이 public Action<int> OnMoneyChanged;라고만 써도 돌아가지만, 그러면 밖에서 이런 짓이 가능하다.
wallet.OnMoneyChanged = null; // 남들의 구독을 전부 날려 버린다
wallet.OnMoneyChanged(9999); // 돈이 안 바뀌었는데 가짜 알림을 보낸다?.Invoke는 구독자가 하나도 없을 때(null) 건너뛰기 위한 것이다. 여기는 유니티 오브젝트가 아니라 일반 C# delegate이므로 ?.를 써도 된다.
그리고 가장 중요한 것. 구독했으면 반드시 해제한다. +=를 하면 Wallet이 MoneyText를 붙들고 있게 된다. MoneyText가 파괴됐는데 -=를 안 했다면, 돈이 바뀔 때마다 이미 파괴된 오브젝트의 함수가 불린다. C++로 치면 옵저버 목록에 댕글링 포인터가 남은 상황이다. +=를 보면 짝이 되는 -=가 있는지 찾아본다.
속성(Attribute): 코드에 붙이는 꼬리표
[SerializeField]. 학원 시절 기억에 파편으로 남아 있던 “직렬화 필드"가 이것이었다.
대괄호로 코드 위에 붙이는 꼬리표다. 그 자체로는 아무 동작도 하지 않는다. 유니티 엔진이 “이 필드에 이런 꼬리표가 붙어 있네” 하고 읽어서 다르게 취급한다. C++03에는 대응하는 기능이 없다.
유니티의 기본 규칙은 이렇다.
| 필드 선언 | Inspector에 보이는가 | 씬 파일에 저장되는가 |
|---|---|---|
public float speed; |
보인다 | 저장된다 |
private float speed; |
안 보인다 | 저장 안 된다 |
[SerializeField] private float speed; |
보인다 | 저장된다 |
여기서 질문이 생겼다. 그럼 반대로, [SerializeField]가 없어도 public이면 Inspector에서 조절할 수 있는 걸까?
그렇다. 실제로 학원 시절의 내 코드는 전부 public 방식이었다. 차이는 다른 코드에서 접근할 수 있느냐뿐이다.
customer.speed = 999f; // public이면 아무 스크립트나 바꿀 수 있다
customer.power = 999f; // [SerializeField] private이면 컴파일 에러값이 이상하게 바뀌었을 때 public이면 프로젝트 전체를 뒤져야 하고, private이면 그 클래스 안만 보면 된다. C++에서 멤버를 public으로 열어 두지 않던 것과 같은 이유다.
하나 더. 프로퍼티는 Inspector에 뜨지 않는다. 프로퍼티는 필드가 아니라 함수이기 때문이다. 밖에서 읽기만 가능하게 하면서 Inspector에도 띄우고 싶으면 이렇게 쓴다.
[SerializeField] private float speed = 2f; // Inspector에서 조정
public float Speed => speed; // 밖에서는 읽기만두 번째 줄의 =>는 람다식이 아니다. { get { return speed; } }를 줄인 읽기 전용 프로퍼티다. 기호가 같으니 구분해서 봐야 한다.
직접 만든 클래스를 Inspector에 띄우려면 그 타입 선언 위에 [System.Serializable]을 붙인다.
[System.Serializable]
public class UpgradeInfo
{
public string name;
public int price;
}
public class Shop : MonoBehaviour
{
[SerializeField] private List<UpgradeInfo> upgrades; // Inspector에 목록으로 펼쳐진다
}자주 보는 다른 속성들이다.
[Header("이동")] // Inspector에 소제목
[Range(0f, 10f)] // 슬라이더로 표시
[Tooltip("초당 손님 수")] // 마우스를 올리면 설명
[RequireComponent(typeof(Rigidbody2D))] // 이 스크립트를 붙이면 Rigidbody2D도 자동으로 붙는다
[CreateAssetMenu(menuName = "Game/Upgrade")] // 에디터의 Create 메뉴에 항목 추가LINQ: 컬렉션에 던지는 SQL
나는 MSSQL을 써 왔기 때문에 이 부분은 SQL에 대응시키니 바로 읽혔다.
SELECT * FROM Enemies WHERE Hp > 0List<Enemy> alive = enemies.Where(x => x.Hp > 0).ToList();| LINQ | SQL로 치면 |
|---|---|
.Where(x => x.Hp > 0) |
WHERE |
.Select(x => x.Name) |
SELECT Name |
.OrderBy(x => x.Price) |
ORDER BY Price |
.First() / .FirstOrDefault() |
TOP 1 |
.Any(x => x.IsDead) |
EXISTS |
.Count(...), .Sum(...), .Max(...) |
COUNT, SUM, MAX |
.ToList() |
결과를 실제 리스트로 만든다 |
꼭 기억할 것 ④ 걸러내는 것은
Where이고, 람다식은 원소 하나만 판정한다나는 “
x => x.Hp > 0은x.Hp를 리턴하는 함수인데, 그중 살아 있는 것만 골라서 리턴한다"고 이해했다. 틀렸다. 람다식은 리스트를 모른다. 원소 하나를 받아 true/false만 대답한다. 루프를 돌며 걸러내는 일은Where가 한다. 그리고>가 있으니 리턴값은 Hp가 아니라 bool이다.
Where의 내부는 대략 이렇게 생겼다.
List<Enemy> Where(List<Enemy> list, Func<Enemy, bool> 조건)
{
List<Enemy> result = new List<Enemy>();
foreach (Enemy x in list)
if (조건(x)) // 여기서 람다식이 원소마다 한 번씩 불린다
result.Add(x);
return result;
}값을 리턴하려면 x => x.Hp가 되어야 한다.
| 람다식 | 리턴 타입 | 어디에 넣는가 |
|---|---|---|
x => x.Hp > 0 |
bool |
Where (조건) |
x => x.Hp |
int |
Select (뽑을 값), OrderBy (정렬 기준) |
List<int> hps = enemies
.Where(x => x.Hp > 0) // 걸러낸다. 람다식은 bool을 리턴
.Select(x => x.Hp) // 변환한다. 람다식은 int를 리턴
.ToList();SELECT Hp FROM Enemies WHERE Hp > 0C++03으로 치면 람다식은 std::remove_if에 넘기던 조건 함수 객체이고, Where는 std::remove_if 같은 알고리즘 쪽이다. 루프는 알고리즘이 돌고, 넘겨준 함수는 원소 하나만 판정한다.
결국 이 한 줄에 오늘 본 것이 다 들어 있다. Where가 받는 인자의 타입이 delegate(Func<Enemy, bool>)이고, 거기에 넣는 값이 람다식이고, 그것으로 컬렉션에 질의하는 것이 LINQ다.
LINQ도 호출할 때마다 내부적으로 객체를 여러 개 만든다. 그래서 람다식의 캡처와 마찬가지로 Update 같은 매 프레임 도는 코드에서는 쓰지 않는다.
학원 시절에 내가 짠 코드를 다시 읽어 보자
위에서 정리한 문법의 상당수는 2022년에 내가 직접 짠 코드에 이미 들어 있었다. 하나씩 다시 읽어 본다.
타워 디펜스의 BuildManager
public static BuildManager instance { get; private set; }{ get; private set; }. 프로퍼티다. 밖에서는 읽기만 되고 대입은 이 클래스 안에서만 된다. static이니 BuildManager.instance로 어디서든 접근하는 싱글톤이었다.
public UnityAction OnChangeMoney;
public int money {
get { return _money; }
private set
{
_money = value;
OnChangeMoney?.Invoke();
moneyText.text = _money.ToString();
}
}UnityAction은 Action과 같은 delegate다. 그리고 money 프로퍼티의 set 안에서 ?.Invoke()를 부르고 있다. 돈이 바뀔 때마다 자동으로 알림이 나가는 구조다. 위에서 설명한 “돈이 바뀌면 이벤트로 알린다"를 2022년의 내가 이미 짜 놓았다.
다만 event 키워드가 없다. 밖에서 = null로 구독을 날릴 수 있는 상태였다. 지금 짠다면 public event Action OnChangeMoney;로 쓴다.
공격 방식의 추상 클래스
public abstract class AttackCommand : MonoBehaviour
{
protected float lastAttackTime = 0f;
public abstract void Attack(Enemy enemy);
}
public class MinigunAttack : AttackCommand
{
public override void Attack(Enemy enemy) { ... }
}abstract void Attack은 C++의 순수 가상 함수(virtual void Attack() = 0;)다. override는 C++03에서는 안 적어도 됐지만 C#은 반드시 적어야 한다. 부모 함수 이름을 잘못 쳐서 재정의가 안 된 실수를 컴파일러가 잡아 준다.
디버그 로그
Debug.Log($"{data.FireRate}, {Time.time}, {lastAttackTime}");$"..."는 문자열 보간이다. C++의 sprintf인데 타입 지정자가 필요 없고 버퍼 오버런도 없다.
그런데 이 줄은 Update에서 매 프레임 불리는 Attack 안에 있었다. 문자열은 불변이라 매 프레임 새 문자열이 힙에 만들어지고 있었다. 위에서 정리한 “매 프레임 도는 코드에서 할당을 피한다"에 정확히 걸리는 코드다. 디버그용이었으니 확인하고 지웠어야 했다.
네트워크 팀 프로젝트의 로비
namespace MoonS
{
public class InLobby : MonoBehaviourPunCallbacks
{
private int PlayersLoadLevel()
{
int count = 0;
foreach (Player p in PhotonNetwork.PlayerList)
{
object playerLoadedLevel;
if (p.CustomProperties.TryGetValue(GameData.PLAYER_LOAD, out playerLoadedLevel))
{
if ((bool)playerLoadedLevel) count++;
}
}
return count;
}
}
}위에서 본 것이 네 가지 들어 있다.
namespace MoonS: 팀원끼리 클래스 이름이 겹치지 않게 내 것을 묶었다.foreach: 반복자 for 문의 C# 버전이다.TryGetValue(..., out playerLoadedLevel):Dictionary조회와out참조 전달이다. 반환값은 성공 여부로 쓰고, 찾은 값은out인자로 돌려받는다.(bool)playerLoadedLevel: 캐스트다. 지금이라면if (v is bool loaded && loaded)로 검사와 변환을 한 번에 쓴다.
카운트다운 코루틴
private IEnumerator StartCountDown()
{
yield return new WaitForSeconds(1.0f);
for (int i = GameData.COUNTDOWN; i > 0; i--)
{
PrintInfo("Count Down " + i);
yield return new WaitForSeconds(1.0f);
}
PrintInfo("Start Game!");
}yield return new WaitForSeconds(1.0f)는 “여기서 함수를 멈추고 1초 뒤에 다음 줄부터 이어서 실행"이다. 서버에서 같은 카운트다운을 만들려면 타이머를 등록하고, 남은 초를 멤버 변수에 저장하고, 콜백이 올 때마다 상태를 보고 분기해야 한다. 코루틴은 그것을 위에서 아래로 읽히는 함수 하나로 쓰게 해 준다. 스레드가 아니라서 락도 필요 없다.
코루틴은 다음 글에서 직접 돌려 보면서 다시 정리한다.
그래서 뭐가 잊은 것이고 뭐가 처음인가
| 2022년 코드에 있던 것 (잊은 것) | 없던 것 (처음 배운 것) |
|---|---|
프로퍼티 { get; private set; } |
람다식과 캡처 |
delegate와 ?.Invoke() |
LINQ |
abstract, override |
event 키워드 |
문자열 보간 $"..." |
[SerializeField] private |
namespace, foreach, out, 캐스트 |
is 패턴 캐스트 |
| 코루틴 |
시간이 가장 많이 든 부분이 오른쪽 칸과 정확히 일치한다. 왼쪽은 다시 보니 떠올랐고, 오른쪽은 개념부터 새로 잡아야 했다.
내가 크게 틀렸던 것 네 가지
이번에 정리하면서 완전히 잘못 알고 있던 것들이다. 다음에 또 틀릴 것 같아서 따로 모아 둔다.
| # | 내가 생각한 것 | 실제 |
|---|---|---|
| ① | readonly는 C++의 const다 |
변수를 바꿔 끼우지 못할 뿐이다. 리스트의 내용은 얼마든지 바뀐다 |
| ② | C#의 struct도 C++처럼 class와 사실상 같다 |
struct는 값 타입(복사), class는 참조 타입(공유)이다 |
| ③ | 프로퍼티는 변수를 그냥 꺼내는 것이다 | getter/setter 함수다. struct를 프로퍼티로 꺼내면 복사본이 나온다 |
| ④ | Where에 넣은 람다식이 걸러서 리턴한다 |
람다식은 원소 하나에 대해 bool만 대답한다. 걸러내는 것은 Where다 |
뭘 배웠지?
- C++에서 C#으로 오면 헤더,
delete, 포인터 기호, 값 치환용#define,const멤버 함수가 없어진다. #define상수는static class안의const로, 전처리기 스위치는 Scripting Define Symbols와#if로 옮긴다.- 배열은 전부
new로 만들고, 크기는 타입이 아니라 객체가 들고 있다(a.Length). class는 참조 타입,struct는 값 타입이다. 복사 여부는 넘기는 쪽이 아니라 타입 선언이 정한다.- 프로퍼티는 변수처럼 생긴 getter/setter 함수다.
transform.position.x = 1이 에러인 이유는 getter가 struct의 복사본을 돌려주기 때문이다. - null 참조는 크래시 대신 예외다. 유니티에서는 예외가 나도 게임이 계속 돌기 때문에 Console을 꼭 본다.
- 유니티 오브젝트에는
?.와??를 쓰지 않는다. - delegate(
Action,Func)는 함수 포인터의 모양이 바뀐 것이다.Func는 마지막 타입이 반환형이다. - 람다식은 그 자리에서 함수를 만들어 함수 포인터로 넣는 것이다. 바깥 변수를 캡처하면 힙 할당이 생긴다.
event는 값이 바뀐 것을 여러 곳에 알리는 장치이고, 구독했으면 반드시 해제한다.[SerializeField]는 private 필드를 Inspector에 띄우고 저장하게 하는 꼬리표다.public필드는 없어도 뜬다.- LINQ는 컬렉션에 던지는 SQL이다. 루프는
Where가 돌고, 람다식은 원소 하나만 판정한다. - 캡처 있는 람다식, LINQ, 문자열 연결은 매 프레임 도는 코드에서 피한다.
생각 해보기
위 내용을 정말 이해했는지 확인하는 문제들이다. 코드를 돌려 보기 전에 먼저 머릿속으로 답을 내 본다.
문제 1
아래 코드에서 컴파일 에러가 나는 줄은 어디일까? 그리고 에러가 나지 않는 줄과 무엇이 다를까? (난이도 : 下)
class Foo
{
public Vector3 field;
public Vector3 Prop { get; set; }
}
Foo foo = new Foo();
foo.field.x = 1; // (가)
foo.Prop.x = 1; // (나)문제 2
아래 코드를 실행한 뒤 a.hp와 b.hp는 각각 얼마일까? Stat이 struct가 아니라 class였다면 답이 어떻게 달라질까? (난이도 : 下)
struct Stat { public int hp; }
Stat a = new Stat { hp = 10 };
Stat b = a;
b.hp = 0;문제 3
readonly로 선언한 리스트에 Add를 하는 것은 되는데, 새 리스트를 대입하는 것은 안 된다. 이것을 C++의 const 포인터 두 가지(T* const와 const T*)에 빗대어 설명해 보자. (난이도 : 中)
문제 4
다음 타입에 담을 수 있는 함수를 하나씩 직접 써 보자. 그리고 각각을 람다식으로도 써 보자. (난이도 : 中)
Action<string>
Func<int, int, bool>
Func<Enemy, string>문제 5
아래 네 개의 람다식은 각각 무슨 타입을 리턴할까? 그리고 이 중 Where에 넣을 수 있는 것은 무엇일까? (난이도 : 中)
x => x.Hp
x => x.Hp > 0
x => x.Name
x => x.Hp > 0 && x.Name != ""문제 6
아래 두 함수 중 하나는 매 프레임 힙 할당이 일어난다. 어느 쪽이고, 왜 그럴까? 할당이 없도록 고쳐 보자. (난이도 : 中)
void Start()
{
button.onClick.AddListener(() => Buy(index));
}
void Update()
{
Enemy target = enemies.Find(e => e.Hp < threshold);
}문제 7
enemy?.Hit();과 if (enemy != null) enemy.Hit();은 일반 C# 객체에서는 같은 코드다. 그런데 enemy가 유니티 오브젝트이고 바로 앞에서 Destroy되었다면 두 코드의 결과가 달라진다. 왜 그럴까? (난이도 : 上)
문제 8
아래 클래스에는 고쳐야 할 곳이 세 군데 있다. 찾아서 고쳐 보자. (난이도 : 上)
public class MoneyText : MonoBehaviour
{
public TMP_Text label;
public Wallet wallet;
void Start()
{
wallet.OnMoneyChanged += Refresh;
}
void Update()
{
label.text = "돈: " + wallet.Money;
}
void Refresh(int money)
{
label.text = "돈: " + money;
}
}힌트. 하나는 접근 제한자, 하나는 매 프레임 일어나는 일, 하나는 짝이 맞지 않는 것이다.
문제 9
foreach로 적 리스트를 돌면서 공격하는데, 적이 죽으면 자기 자신을 그 리스트에서 빼도록 되어 있다. 어떤 일이 벌어질까? C++의 std::vector에서 같은 코드를 짰다면 어떻게 되었을까? 그리고 어떻게 고치면 될까? (난이도 : 中)
문제 10
서버에서 #ifdef EVENT_A로 관리하던 이벤트를 클라이언트에서도 똑같이 #if EVENT_A로 관리하려고 한다. 문법적으로는 아무 문제가 없다. 그런데 왜 클라이언트에서는 이 방식을 피하는 것이 좋을까? 대신 무엇으로 관리해야 할까? (난이도 : 中)