[ 얕은 복사 vs 깊은 복사 #1 ]

 

 

이 상태로 실행을 빌드 하게 되면 빌드가 정상적으로 되는것을 알 수 있다.

 

복사 생성자도 만들어주지 않았고, 복사 대입 연산자를 만들어주지 않았음에도 말이다.

 

이는 우리가 직접적으로 만들어주지 않았다면, 컴파일러가 암시적으로 만들어주기 때문이다.

 

그러나 기본적으로 컴파일러가 만들어준 버전은 메모리에 있는 데이터를 똑같이 복사해주는것이다. 

 

그럼 뭐가 문제냐? hp =200 들어가 있는걸 그대로 복사해준다는데?

 

일반적인 int 변수 같은건 상관 없는데 참조값이나 포인터 값이 들어가 있다면 문제가 완전히 달라진다. 

 

다음과 같은 클래스가 있다고 생각해보자

 

Knight가 Pet 을 이런식으로 가지고 있다라고 한다면 

 

생각해볼 문제는 3 가지 정도이다. 

 

1. 생명주기가 Knight가 없어질때 Pet도 같이 없어진다는 점이고

 

2. 가장 큰 문제는 Pet이 만약 엄청나게 큰 byte를 차지하고 있다라고 한다면 Knight는 생성될때 마다 항상 이 Pet의 큰 바이트를 같이 가지고 있게 되는것이다.

 

3. 세번째 문제는 Pet을 상속 받는 여러가지 Pet 이 있다고 할때 Knight 가 TutlePet 등등 여러가지 pet을 넣어줄 수 없다는 점이다. 

 

 

그래서 일반적으로 어떤 클래스에서 다른 클래스를 들고 있을때

일반적으로 포인터나 참조값으로 들고 있는것으로 만들게 되는게 훨씬 깔끔하다. 

 

 

그런데 문제는 아래이다. 

 

결국에는 knight1 , knight2 도 knight를 복사 했기 때문에 똑같은 pet( 같은 포인터 )을 공유하게 된다.

 

knight 는 세명이 생겼지만, pet은 같은 pet을 가지고 있게 된것이다. ( 문제

 

이런 형태의 복사를 얕은 복사라고 한다.

 

 

shallow copy : 멤버 데이터를 비트열 단위로 똑같이 복사한다  ( 메모리 영역 값을 그대로 복사 )

 

포인터는 주소값 바구니 -> 주소값을 똑같이 복사 -> 동일한 객체를 가리키는 상태가 됨.

 

 

 

Knight가 pet의 생명주기를 관리하기 위해 다음과 같이 생성자에서 pet을 생성하고 소멸할때 지워주게 된다면 ( 물론 생성자에서 동적할당을 하는 것이 좋은 패턴이지는 않긴한데..(강사님 말) )

 

실행시키면 뻗는것을 알 수 있다.

 

 

얕은 복사를 통해 knight , knight1,2 가 모두 같은 _pet을 가리키고 있고

~Knight() 소멸자를 통해 delete _pet이 세번씩이나 호출 되는것이다. ( double free 문제

 

결국에는 얕은 복사가 문제 였다.


 

[ 깊은 복사 Deep Copy ]

 

만약 멤버 데이터가 참조( 주소 ) 값이라면, 데이터를 새로 만들어준다.

( 원본 객체가 참조하는 대상까지 새로 만들어서 복사한다는 개념 )

 

포인터는 -> 새로운 객체를 생성해준 다음에 -> 상이한 객체를 가리키는 상태가 됨.

 

[Stack] : Knight1 [ hp ] [ 0x1000 ] -> [Heap] 0x1000 Pet [     ]   각자 자기 전용 Pet이 만들어진다는 느낌이다.

[Stack] : Knight2 [ hp ] [ 0x2000 ] -> [Heap] 0x2000 Pet [     ]

[Stack] : Knight3 [ hp ] [ 0x3000 ] -> [Heap] 0x3000 Pet [     ]

 

 

기본적으로 어떤 클래스 안에 참조나 포인터가 들어가게 되면은 곧이 곧대로 컴파일러가 기본적으로 만들어주던

[ 복사 대입 연산자 ]   [ 복사 생성자 ]  를 사용할 수는 없는 문제가 생기게 된다. 

=> 오버로딩을 통해 명시적으로 만들어 줘야한다. 

 

 

 

물론, 100% 는 아니고, 주소값 자체를 복사하지 않아야 될 경우에 반드시 깊은 복사를 해줘야 한다. 

 

 


 

 

[ 얕은 복사 vs 깊은 복사 #2 ]

 

 

 

아주 중요한 부분이 하나 있다... (  맨날 중요하데.. )

 

 

우선 결과부터 말하자면 

 

-----------------------------------------------------------------------------------------------------------------------------------

 

 

- [ 암시적 복사 생성자 ] 가 호출 된다면 

1) 부모 클래스의 복사 생성자 호출 ( 상속 받았을 경우 )

 - 부모 클래스인 Player의 복사 생성자가 호출 되었다.

 

2) 멤버 클래스의 복사 생성자 호출 ( 멤버 변수에 클래스 변수가 있을때 )

* 여기서 멤버 클래스라 함은 Knight 클래스 안에서 _petPet* _pet 이 아니라 Pet pet 이렇게 물고 있을때를 말한다.

- Pet 을 멤버 변수로 가지고 있었는데 Pet 클래스의 복사 생성자가 호출 되었다.

 

3) 두 경우가 모두 아니고 멤버가 모두 기본 타입일 경우 메모리 복사 ( 얕은 복사, shallow copy ) 를 수행한다. ( 내가 아는 암시적 복사 생성자 )

( 포인터의 경우 3번 케이스에 속한다. )

 

그렇다면 Knight 클래스 안에  명시적으로 복사 생성자를 넣어놓으면 어떻게 될까?

 


- [ 명시적 복사 생성자 ] 를 호출 해주면

- 모든 것을 우리가 컨트롤 하겠다고 받아 들여서 모두 기본만 호출해주고 나머지는 너가 알아서 해라라고 만들어 놨다. ( 책임 전가.. )

 

1) 부모 클래스의 기본 생성자 호출 ( 상속 받았을 경우 )

- Player 클래스(부모 클래스)의 기본 생성자 호출 

 

2) 멤버 클래스의 기본 생성자 호출 ( 멤버 변수에 클래스 변수가 있을때 )

* 여기서 멤버 클래스라 함은 Knight 클래스 안에서 _pet Pet* _pet 이 아니라 Pet pet 이렇게 물고 있을때를 말한다.

 

 

이렇게  Knight 의 복사 생성자를 명시적으로 호출하게 되면 

 

Player 도 그렇고 Pet 도 그렇고 기본 생성자를 호출해주게 된다.

 

문제는 아래 처럼 knight._level = 100 으로 했을때

 

복사 생성자에 의해 똑같이 복사가 되어야 하는데 

 

level 같은 경우 Player 의 변수 이기 때문에 Knight 의 복사 생성자에서 뭔가 작업이 이루어지기에는 무리가 있다. 

(   this->_level = knight._level; 하면은 되긴함 ) -> 변수를 public 으로 했기 때문에 가능.

 

그래서 아래처럼 복사 생성자에 의해 복사가 됐음에도 knight2 의 level은 0인 것을 볼 수 있다.

 

C++ 은 이런게 어렵다. ( 꼭 기억해야된다. ) 

 

그래서 문제는 Knight 복사 생성자를 호출 할때 선처리 영역에서 Player의 기본 생성자를 호출 하는게 문제이기 때문에

Player의 기본 생성자가 아닌 복사 생성자를 호출하게끔 해줘야 한다.

 

 

기본적으로 자식 클래스의 생성자가 호출 될때는 부모 클래스의 생성자가 호출 되는데

선처리 역역에서 Player() : 기본생성자 가 아닌 Player(knight) : 복사 생성자 를 호출 해 줄 수 있다.

 

마찬가지로 

_pet 도 기본 생성자가 호출되는게 문제이기 때문에 

 

 

명시적으로 _pet도 복사 생성자를 호출해줘야한다.

 


복사 대입 연산자의 같은 경우도 비슷하다.

 

- [ 암시적 복사 대입 연산자 ] 가 호출 된다면

 

1) 부모 클래스의 복사 대입 연산자 호출 ( 상속 받았을 경우 )

 

2) 멤버 클래스의 복사 대입 연산자 호출 ( 멤버 변수에 클래스 변수가 있을때 )

* 여기서 멤버 클래스라 함은 Knight 클래스 안에서 _pet Pet* _pet 이 아니라 Pet pet 이렇게 물고 있을때를 말한다.

 

3) 두 경우가 모두 아니고 멤버가 모두 기본 타입일 경우 메모리 복사 ( 얕은 복사, shallow copy ) 를 수행한다. 

( 포인터의 경우 3번 케이스에 속한다. )

 

 

 


- [ 명시적 복사 대입 연산자 ] 를 호출 해주면

- 모든 것을 우리가 컨트롤 하겠다고 받아 들여서 모두 기본만 호출해주고 나머지는 너가 알아서 해라라고 만들어 놨다. ( 책임 전가.. )

 

1) 부모 클래스의 기본 생성자 호출 ( 상속 받았을 경우 )

 

2) 멤버 클래스의 기본 생성자 호출 ( 멤버 변수에 클래스 변수가 있을때 )

* 여기서 멤버 클래스라 함은 Knight 클래스 안에서 _pet Pet* _pet 이 아니라 Pet pet 이렇게 물고 있을때를 말한다.

 

 

 

결과를 확인하기 위해 복사 생성자 때와 동일한 예시를 가지고 오자면,

 

암시적으로 복사 대입 연산자가 호출 될때 아래와 같이 Player와 Pet 클래스 역시 복사 대입 연산자가 호출되는것을 볼 수 있다. 

 

그리고 이렇게 암시적으로 컴파일러가 만든 복사 대입 연산자를 호출 하게 되면, 마찬가지로

knight의 level이 100임에도 부모클래스 Player의 변수인 level은 복사 되지 않고,

pet 역시 초기화 되지 않은것을 알 수 있다.

 

 

 


그래서 명시적으로 복사 대입 연산자를 호출하게되면 컴파일러가 자식클래스나 클래스 멤버 변수들을 복사 대입연산자로 복사하지 않기 때문에, 직접적으로 챙겨줘야된다. ( 아래 처럼 )

 

 

 

결론 : 

 

왜 이렇게 혼란스러울까?

결국 객체를 '복사'한다는것은 두 객체의 값들을 일치시키려는 것.

따라서 기본적으로 얕은 복사 방식으로 동작

 

그걸 customize 해서 명시적으로 복사 대입, 복사 생성 을 할 경우

다른 것들은 우리가 명시적으로 복사해줘야한다. ( 모든 책임을 프로그래머한테 위임하겠다는 것 )

 

 


[ 캐스팅 ( 타입 변환 ) 4 총사 ]

참고로 면접에서 맨날 나오는 기본기라고 한다.

 

1. static_cast

 

- 상식적인 캐스팅만 허용해준다. ( 타입 원칙에 비춰볼 때 )

 

ex) int <-> float ( 정밀도는 완벽하진 않지만 어느정도 말이 된다. )

ex) Player* -> Knight* ( 다운 캐스팅 : 부모 타입에서 자식 타입으로 )

 

 

 

 

위의 예시 처럼 컴파일러가 오류를 내뿝는것을 볼 수 있다.

(*업캐스팅은 따로 캐스팅을 해주지 않더라도 컴파일러가 안전하다고 간주한다.)

 

이런 상황에서 우리가 사용하던 캐스팅이 ( 기존 C 스타일 캐스팅 )

 

 

그걸 c++ 에서는 static_cast<> 로 해준다.

 

 

단, 안전성은 보장 못함. ( Player도 Knight 일 수 있으니깐 아예 말이 안되는것은 아니다. 그러니깐 static cast 가 가능하다. ) 

 

 

두 경우 일때는 명확히 잘못된 캐스팅인데도 컴파일러는 잡아내지 못한다.

 

Archer라는게 p의 원본임에도 불구하고 이걸 다시 Knight로 static_cast 하려고 한다. ( 잘못된 방식으로 타입변환한것이다. ) 이럴 경우, Archer랑 Knight가 아예 설계도가 달라서,서로 크기가 다르다고 한다면  엉뚱한 메모리에 접근하게 될 수 있다 

엄청 위험한 작업!

 

근데 우리가 위에서 만든 예시는 당연히 p가 바로 위에서 만들었으니깐 무슨 타입인지 알겠는데

저~ 멀리서 왔을때 단 애가 만들어서 넘겨 줬을때는 p가 진짜 Knight인지 Archer 인지 알기가 쉽지 않다.

 

그럴 경우,

 

 

dynamic_cast 를 사용해도 좋고,

 

or

 

Player에 저번처럼 enum 으로 PLAYER_TYPE 을 변수로 넣어준 다음에 ( 안전 장치 )

타입 캐스팅 전에   p.PLAYER_TYPE 을 체크해줘서 안전하게 맞는 캐스팅을 해주는것도 방법이다. 

 


2. dynamic_cast

- static_cast 단점 보완해준거

- 상속 관계에서의 캐스팅에서만 사용

- RTTI ( RunTime Type Information) 활용.

- RTTI를 사용하고 싶으면 virtual 함수가 하나라도 있어야 한다.

 

RTTI = VFT(virtual func Table ) 활용하는것. 런타임에서 관리하는 정보이다 ( <-- 이것을 유식하게 표현한말.)

- 그래서 Virutal 함수가 하나도 없는 객체에 dynamic Cast를 한다고 하면 오류가 뜬다.

RTTI 를 지원하지 않는, 즉 다형성을 활용하지 않는다 = virtual 함수를 하나도 만들지 않았다.

 

이제는 RTTI 가 적용이 되서 에러 없이 dynamic_cast가 사용되는것을 볼 수 있다.

 

정리하자면, virtual 함수를 딱 하나라도 만들면, 객체가 메모리에 올라갈때, 맨 위에 가상 함수 테이블의 주소를 저장해놓는데 ( VFT ), 이걸 이용해서 casting 할때 맞는 타입인지 체크하고 캐스팅 해준다.

 

만약 잘못된 타입으로 캐스팅 헀으면 , nullptr 을 반환한다.

 

이렇게 static_cast 를 했을때에는 k1이 아래의 p의 주소를 가리키고 있고 맨 처음 값인 p의 VFT 의 주소를 가리키고 있는 상태인것을 알 수 있다. ( 잘못된 캐스팅이 일어난 경우 ) 

 

 

 


 

 

반면 dynamic 캐스팅은 진짜로 맞는 방식대로 캐스팅했는지 확인 할 수 있다.

k2 는 dynamic_cast 를 해줬기 때문에 nullptr을 가지고 있는것을 알 수 있다. 

이렇게 잘 못된 방식으로 casting 하면 nullptr 값이 나온다.

 

그래서 만약 p가 진짜 Knight 인지 아닌지 체크하려면 dynamic_cast 를 활용하면 된다. 

 

 

그렇지만 dynamic_cast는 static_cast 보다는 느리게 작동한다는 단점이 있다.

그래서 아까처럼 PLAYER_TYPE 을 넣는게 빠르게 하는 방법 일 수 도있다. ( 대부분의 경우 )

 

 

3. const_cast

const를 붙이거나 때거나 할때 사용한다.

대부분의 경우에서 이건 거의 사용할 일이 없다.

 

 

 

4. reinterpret_cast

 

가장 위험하고 강력한 형태의 캐스팅

 

re-interpret : 다시-간주하다/ 생각하다

~ 포인터랑 전혀 관계없는 다른 타입 변환 

 

Knight* k2 = new Knight();
__int64 address = k2; // 당연히 컴파일 오류.

애시 당초에 Knight* 를 __int62로 형변환 할 수 없다. ( 통과안된다 )

 

물론 C 스타일 캐스팅(명시적)을 해주면 되긴 된다.

__int64 address = (__int64)k2;

 

이 경우도 마찬가지로 혹시 k2가 포인터인지 알면서 한건지 아닌건지 불명확하다. ( 보는 사람, 작성한 사람 둘다 )

 

그래서 아예 생뚱맞은 다른 타입으로 변환하겠다 하는것은  reinterpret을 사용한다. 

 

내가 뭘 캐스팅하려는지 알고 의도적으로 바꾸는거야~~ 라는 의미도 내포하고 있다.

__int64 address = reinterpret_cast<__int64>(k2);

 

 

satic_cast 나 dynamic_cast 는 확실하게 둘 사이의 상관관계가 있을때 ( 부모-자식 이나 자식-부모 관계 처럼 상속 관계가 있을때 활용할 수 있는것이다. ( 아무때나 쓸 수 있는게 아니다 .)

 

reinterpret_cast 는 아무런 상관관계가 없는 클래스들 끼리도 캐스팅해준다.

 

Dog* dog1 = reinterpret_cast<Dog*>(k2);

'C++과 언리얼로 만드는 게임 개발 > Part1. C++ 문법' 카테고리의 다른 글

Day10 ( 79.46% )  (0) 2022.04.17
Day9 ( 71.42% )  (0) 2022.04.04
Day8 - ① 동적할당 / ★타입 변환★( 61.60% )  (0) 2022.03.31
Day7 ( 54.46% )  (0) 2022.03.30
Day6 ( 52.67% )  (0) 2022.03.30

+ Recent posts