포스트

(C#) 17. 객체지향의 시작 (OOP)

속성과 기능을 한 덩어리로 묶는 class와 struct를 처음 써봤다. 같은 코드인데 struct는 복사되고 class는 공유되는 것을 직접 확인하고, 그 차이가 어디서 오는지 정리했다.

(C#) 17. 객체지향의 시작 (OOP)

변수와 함수가 흩어져 있으면

지금까지는 데이터를 변수로 따로 두고 함수로 처리했다. 기사 하나를 다루려면 knightHp, knightAttack 같은 변수를 만들고, 기사가 둘이면 knight2Hp, knight2Attack을 또 만들어야 한다.

관련 있는 데이터와 그걸 다루는 기능을 한 덩어리로 묶는 게 객체다.

1
2
3
Knight
  속성: hp, attack, pos
  기능: Move, Attack, Die
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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
using System;

namespace StartOOP
{
    // 객체 (OOP Object Oriented Programming)

    // Knight 

    // 속성: hp, attack, pos
    // 기능: Move, Attack, Die

    // Ref 참조
    class Knight
    {
        public int hp;
        public int attack;

        public Knight()
        {
            hp = 100;
            attack = 10;
            Console.WriteLine("생성자 호출!");
        }

        public Knight(int hp)
        {
            this.hp = hp;
            Console.WriteLine("int 생성자 호출!");
        }

        public Knight Clone()
        {
            Knight knight = new Knight();
            knight.hp = hp;
            knight.attack = attack;
            return knight;
        }

        public void Move()
        {
            Console.WriteLine("Knight Move");
        }

        public void Attack(){
            Console.WriteLine("Knight Attack");
        }
    }

    // 복사
    struct Mage
    {
        public int hp;
        public int attack;
    }

    class Program
    {
        static void KillMage(Mage mage)
        {
            mage.hp = 0;
        }

        static void KillKnight(Knight knight)
        {
            knight.hp = 0;
        }

        static void Main(string[] args)
        {
            //Mage mage = new Mage();   // struct 는 new를 생략가능
            Mage mage;
            mage.hp = 100;
            mage.attack = 50;
            //KillMage(mage);

            Mage mage2 = mage;
            mage2.hp = 0;

            Knight knight = new Knight();   // 객체 생성
            knight.hp = 100;
            knight.attack = 10;
            // KillKnight(knight);

            //Knight knight2 = knight;
            //knight2.hp = 0;

            Knight knight2 = knight.Clone();
            knight2.hp = 0;

            Console.WriteLine($"mage.hp: {mage.hp}");
            Console.WriteLine($"knight.hp: {knight.hp}");
            Console.WriteLine($"knight2.hp: {knight2.hp}");
        }
    }
}

실행 결과

이 예제가 확인하려는 것

주석 처리된 줄들이 이 편의 핵심이다. 하나씩 풀어서 돌려보면 structclass의 차이가 그대로 드러난다.

1
2
3
Mage mage2 = mage;
mage2.hp = 0;
// mage.hp 는 100 그대로
1
2
3
Knight knight2 = knight;    // 주석 처리된 줄
knight2.hp = 0;
// knight.hp 도 0 이 된다

같은 문법인데 결과가 다르다. Magestruct이고 Knightclass이기 때문이다.

  • struct(값 형식): 대입하면 내용이 통째로 복사된다. 둘은 별개다
  • class(참조 형식): 대입하면 주소가 복사된다. 둘이 같은 객체를 가리킨다

11편에서 함수 인자로 넘길 때 본 그 차이가, 대입에서도 똑같이 나타난다. KillMageKillKnight도 같은 실험이다. KillMage(mage)를 불러도 mage.hp는 100이고, KillKnight(knight)를 부르면 0이 된다.

Clone 이 있는 이유

1
2
Knight knight2 = knight.Clone();
knight2.hp = 0;

class인데도 원본에 영향을 안 주려면, 새 객체를 만들어 값을 하나씩 옮겨야 한다. 그게 Clone이 하는 일이다.

1
2
3
4
5
6
7
public Knight Clone()
{
    Knight knight = new Knight();
    knight.hp = hp;
    knight.attack = attack;
    return knight;
}

여기서 knight.hp = hp;의 오른쪽 hp현재 객체의 hp 다. this.hp라고 써도 같다. 이름이 겹치지 않아서 this를 생략했다.

Clone 안에서 new Knight()를 부르니 “생성자 호출!”이 한 번 더 찍힌다. 실행 결과에 생성자 메시지가 두 번 나오는 이유가 이것이다.

이렇게 멤버를 하나씩 옮기는 걸 얕은 복사라고 한다. 멤버 중에 또 다른 클래스가 있으면 그건 주소만 복사되어 여전히 공유된다. 완전히 분리하려면 그 멤버도 Clone해야 한다. 지금은 멤버가 int뿐이라 문제가 안 된다.

struct 는 new 를 생략할 수 있다

1
2
3
4
//Mage mage = new Mage();   // struct 는 new를 생략가능
Mage mage;
mage.hp = 100;
mage.attack = 50;

struct는 변수 선언만으로 메모리가 잡힌다. 다만 모든 필드를 대입하기 전에는 읽을 수 없다.

1
2
Mage mage;
Console.WriteLine(mage.hp);   // 컴파일 에러: 할당되지 않은 지역 변수를 사용했습니다

new Mage()로 만들면 모든 필드가 0으로 초기화되어 바로 읽을 수 있다.

classnew 없이는 아무것도 안 된다. 선언만 하면 null이고, 접근하면 NullReferenceException이다.

1
2
3
4
Knight knight;
knight.hp = 100;              // 컴파일 에러
Knight knight2 = null;
knight2.hp = 100;             // 실행 중 NullReferenceException

언제 struct 를 쓰나

class로 만들면 객체 하나마다 힙에 메모리를 잡고, 나중에 GC가 정리한다. struct는 스택이나 담고 있는 객체 안에 그대로 들어가서 그 비용이 없다.

대신 대입할 때마다 통째로 복사되니 크면 오히려 손해다. 기준은 대략 이렇게 잡았다.

  • 크기가 작고(대략 16바이트 이하), 값 자체로 의미가 있고, 만들면 안 바뀌는 것 → struct
  • 그 외 대부분 → class

좌표(Vector3), 색(Color), 크기 같은 게 struct에 맞는다. .NET의 int, double, DateTime이 전부 struct다.

게임에서 좌표를 매 프레임 수천 개씩 다루는데 그게 전부 class라면 GC가 계속 돌아 프레임이 끊긴다. struct를 쓰는 실질적인 이유가 이것이다.

C++의 struct/class 차이는 접근 지정자 기본값뿐이라 완전히 다른 얘기다. C#은 값 형식이냐 참조 형식이냐라는 근본적인 차이다.

public 이 붙은 이유

필드에 public이 붙어 있어서 밖에서 knight.hp = 100;처럼 직접 고칠 수 있다.

편하지만 위험하다. 체력이 음수가 되거나 최대치를 넘는 걸 막을 방법이 없다. Knight 안에 규칙을 넣어도 밖에서 그냥 대입해버리면 소용없다.

이걸 막는 게 21편의 은닉성이고, C#에서 실제로 쓰는 형태는 32편의 프로퍼티다.

정리하면

  • 관련된 데이터와 기능을 하나로 묶은 것이 객체다
  • struct는 값 형식이라 대입할 때 복사되고, class는 참조 형식이라 주소가 복사되어 같은 객체를 가리킨다
  • class를 복사하려면 새 객체를 만들어 값을 옮기는 Clone 같은 함수가 필요하다
  • 멤버에 또 다른 클래스가 있으면 얕은 복사로는 분리되지 않는다
  • structnew 없이 선언할 수 있지만 모든 필드를 채우기 전에는 읽을 수 없다
  • 작고 값 자체로 의미가 있는 것만 struct, 나머지는 class
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.