CPP Module 01 (1)
“CPP Module 01에 대하여”
Memory allocation, pointers to members, references, switch statement
C++의 메모리 할당, 포인터와 멤버들, 참조들, switch문 … C++에서도 메모리 관리는 계속된다.
ex00 BraiiiiiiinnnzzzZ
- Zombie 클래스를 만들어야 한다. string 멤버 변수 name을 가지고,
void announce(void)라는 멤버 함수를 가진다. 좀비는 announce 하면서
<name>: BraiiiiiiinnnzzzZ...
라고 한다. name을 출력할 때, “<”, “>”(brackets)는 제거해야 하는데 이는 42의 유구한 전통이다. 고사기에도 그렇게 적혀있다.
그리고 함수를 2개 만들어야 한다.
Zombie* newZombie(std::string name)과void randomChump(std::string name)이라는 녀석들이다.(1) 첫 번째
newZombie(string name)는 좀비를 만들어서 이름을 붙이고 함수의 스코프 바깥에서 사용할 수 있도록 반환하는 것이다. 매우 매우 중요한 포인트(2) 두 번째
randomChump(string name)는 좀비를 만들어서 이름을 붙이고 좀비가 annouce하면 된다. 이상 무.
함수 스코프 바깥에서 사용할 수 있도록 반환
- 중요해서 한번 더 적었다. 이게 무슨 소리일까? 아래 코드를 한번 보자. 출력값은 굳이 안적었다. 출력값이 중요한 코드가 아니다.
#include <stdio.h>
int *getStackPtr() {
int var = 42;
int *ptr = &var; // 스택에서 할당된 포인터를 함수 스코프 밖으로 빼려고 함
return ptr;
}
int main()
{
int *ptr = getStackPtr();
printf("%d\n", *ptr);
return 0;
}
이런 식으로 쓰는 코드는 본 적이 없을 것이다. 당연하다. 이런 코드를 적는 자들은 모두 닌자에게 살해당했기 때문이다…
getStackPtr()를 보면 스택에 int 변수 하나를 할당하고, 포인터에 그 주소를 담아서 반환한다. 그런데 var은 지역 변수이기 때문에 스코프가 getStackPtr()를 벗어나는 순간 해제되고, ptr의 주소는 해제된 곳을 가리키게 된다. 즉 ptr은 댕글링 포인터가 된다. 댕글링 포인터(Dangling Pointer)는 이미 해제된 메모리를 가리키거나 더 이상 유효하지 않은 메모리 위치를 가리키는 포인터다. 이는 프로그램의 안정성과 예측 가능성을 저해하며, 예기치 않은 동작 또는 프로그램 충돌을 유발한다고 한다. 그러니까 만약 이런 식으로 스택 포인터Zombie*를 반환하면, 그게 함수 스코프 바깥에서 사용할 수 없도록 반환하는 거다. 이러면 안 된다. 그러면 함수 스코프 바깥에서 사용할 수 있도록 반환한다는 건 뭘까? 우리는 이미 답을 알고 있다. 수백번도 넘게 해온 행위이다. 바로 메모리의 동적 할당(malloc, calloc …)이다.
** 스택과 힙에 대해 자세히 알고 싶다면 C++ 08.10 - 스택과 힙 (Stack and Heap) 참고
- 동적 할당과 스택에 포인터를 만드는 것의 차이에 대해 공부한 적이 있을 것이다. 메모리를 동적 할당하면 메모리 세그먼트(스택, 힙, 코드, 데이터) 중에서는 힙을 쓰게 되며, 스택에 비해 더 큰 메모리를 가져갈 수 있지만 속도가 느리고, 명시적으로 해제할 때까지 유지되고, 포인터를 역참조하여 value에 접근할 수 있다. 여기에 더해 중요한 포인트가 바로 메모리를 직접 제어할 수 있기 때문에 데이터를 함수 스코프 밖으로 끄집어 낼 수 있다는 점에 있다. 즉 함수 스코프 바깥에서 사용할 수 있도록 반환할 수 있다. 이 과제는 메모리를 동적으로 할당할 것을 요구한다.
newZombie(std::string name)
#include "Zombie.hpp"
Zombie *newZombie(std::string name)
{
Zombie *heap = new Zombie(name);
return (heap);
}
문자열 name을 매개변수로 받아 새로운 좀비 클래스 하나를 동적 할당하여 스코프 바깥으로 반환하는 코드이다. 간단명료 그 자체.
C++에서는 malloc 대신 new를 사용한다. 쪼금만 살펴보자면 대략 이렇게 생겼다.
void* operator new ( std::size_t count );
- 또한 세부적으로는 아래와 같다.
void* operator new(std::size_t sz)
{
std::printf("1) new(size_t), size = %zu\n", sz);
if (sz == 0)
++sz; // avoid std::malloc(0) which may return nullptr on success
if (void *ptr = std::malloc(sz))
return ptr;
throw std::bad_alloc{}; // required by [new.delete.single]/3
}
** 출처, CPP Reference, operator new, operator new[]
- 내부적으로 malloc을 사용하고, 에러 발생 시에는 exception을 던진다. exception을 처음 볼 수도 있는데, 일단 예외 처리의 일종이라고 생각하자. 이 과제에서는 사용하지 않는다.
생성자와 소멸자
- 생성자와 소멸자가 무엇인지에 대해서 굳이 나열하진 않겠다. 중요한 점은 결국 클래스를 생성할 때는 생성자가, 클래스가 없어질 땐 소멸자가 반드시 호출된다는 것이다. 뭐 당연한 소리를 하냐고 볼 수도 있겠지만 반드시가 가지는 힘이 상당히 강력하다. 우리가 변수를 선언하면 초기화가 필요하다는 사실을 알면서도, 또 메모리를 동적 할당하면 해제가 필요하다는 사실을 알면서도, C에서 그 많은 segmentation fault와 data leaks를 목격했던 이유가 바로 저 “반드시”의 부재에 있다. 내가 생각하는 생성자와 소멸자의 강력한 힘은 그 자체가 초기화와 해제에 관한 명시적인 course라는 점에 있다.
돌다리를 두들겨보고 건너는 것과 그냥 건너는 것의 차이
- 클래스의 존재와 그 특징들은 상속/다형성과 더불어 C++가 가지는 뚜렷한 특징인데, 여기서는 C의 구조체와 C++의 클래스에 대해 조금만 이야기해보도록 하겠다.
(1) 멤버 함수
- 구글링을 하면서 막 찾아보다가 C 구조체로 C++의 클래스를 흉내내는 코드를 본 적이 있다(시간나면 한번 해보는 것도 나쁘지 않을 것 같다). 역시 C 구조체와 C++ 클래스의 가장 눈에 띄는 차이는 멤버 함수일 것이다. C 구조체에는 함수 자체를 담을 수는 없으며, 비슷한 걸 흉내내려면 함수 포인터를 써야한다. 아래는 내가 C 구조체로 C++ 클래스를 흉내냈던 코드다.
struct c_Person
{
char* name;
int age;
int(*getAge)(c_Person *);
void(*setAge)(c_Person *, int);
string(*getName)(c_Person *);
void(*setName)(c_Person *, string);
};
class Person
{
private:
string name;
int age;
public:
Person();
Person(string name, int age);
string Getname() const;
int GetAge() const;
void SetName(string name);
void SetAge(int age);
~Person();
};
- 이렇게 보면 둘이 비슷해 보일지도 모르겠다. 어차피 함수 내부 구현하는건 똑같을텐데 C도 그냥
getAge(),setAge(),getName(),setName()구현해서 매칭해주면 되는거 아닐까하고 생각할수도 있다. 물론 구조체, 클래스를 하나만 쓸거면 크게 상관없을거다. 사람이 천 명이면 어떨까? 구조체 천 개에 일일히 함수 매칭해주고, 구조체 포인터 보내서 이름, 나이 일일히 초기화할 거 생각하면 벌써부터 군침이 싹돈다. 반면에 클래스는 그렇지 않다. 클래스 안의 멤버 함수들은 클래스가 생성되면서 단 한번만 생성된다. 이후 저 클래스로 만들어진 객체들은 동일한 함수 포인터들을 돌려쓴다. 함수를 일일히 매칭해줄 필요가 없다. 또 생성자들을 통해 멤버 변수들을 명시적으로 초기화할 수 있다. 실수가 발생할 확률이 적다는 거다. 적어도 char*로 된 이름 천 개 할당하면서 생기는 실수보다는 훨씬 적을 것 같다.
(2) this 포인터
- 이건 또 클래스가 지닌 특별한 기능이다. 기능에 대한 상세한 내용을 알고 싶다면 C++ 09.10 - this 포인터을 참고하자. this 포인터는 자기 자신에 대한 포인터인데, 컴파일러 상에서 항상 멤버 함수의 매개 변수에 추가된다. 예를 들어 Person의 age를 반환하는
int getAge()에 대해 생각해보자.
int Person::getAge(const Person* this) const
{ // int Person::getAge() const
return (this->age);
}
- 매개 변수에 포함된
const Person* this가 바로 this 포인터이다. 우리가 작성하는 코드 상에서는 등장하지 않지만, 컴파일러 단에서 추가되어 객체를 식별한다. 여러 객체들이 똑같이 getAge()를 호출하더라도, 호출하는 객체가 다르면 서로 다른 값을 반환할 수 있는 비결이다.
ex01: Moar brainz!
- 이번 exercise는 객체 배열의 동적 할당과 관련이 있다. 단도직입적으로 객체 배열을 생성하는 요령에 대해 알아보자.
객체 배열의 선언 및 초기화
Classname* ptr = new Classname[size];
- 이걸 Zombie에 대입시켜보면?
Zombie* arr = new Zombie[size];
- 이렇게 하면 size만큼의 Zombie 배열을 얻을 수 있다. 다만 c++98에서 이런 식으로 객체 배열을 선언할 때는 매개 변수가 있는 생성자를 호출할 수가 없다(c++11부터 제한적으로 가능하다고 한다). 그 말인즉슨, 저렇게 선언된 Zombie 클래스 각각의 이름은 빈 문자열로 초기화 되어있고, 우리가 수동으로 이름을 초기화해주어야 한다는 거다. 반복문을 돌면서 알맞은 처리를 해주면 되겠다.
객체 배열의 해제
- new로 선언했으니, delete로 해제하는 것도 동일하다. 그런데 해제하는 모양이 살짝 다르다. 아래와 같이 해제한다.
Zombie* arr = new Zombie[size];
...
delete[] arr;
- delete[]를 통해, 배열의 각 요소에 있는 소멸자들을 전부 호출할 수 있다.
delete arr라고만 쓰면 배열의 첫 번째 원소의 소멸자만을 호출하게 될 것이다.
ex02: HI THIS IS BRAIN
참조자
- 레퍼런스(참조자)에 대해 알아야 한다. 사실 그냥 가져다쓰는 것 자체는 별로 안어려운데, 제대로 알려고 하면 어렵다. 참조자는 두고두고 써야하기 때문에 제대로 아는 것이 중요하다. 열심히 공부해보도록 하자. 참조자에 대한 설명이 자세한 사이트 두 곳을 소개한다.
1. 모두의 코드, 씹어먹는 C++ - <2. C++ 참조자(레퍼런스)의 도입>
2. 참조자에 대하여
- 간략하게나마 참조자들의 특징들을 살펴보고 가도록 하자.
(1) 선언할 때, 반드시 어떤 변수를 참조하는지 정해야 한다.
(2) 참조자가 특정 변수를 참조하게 되면, 대상을 다시 변경할 수 없다.
=> 잘 알려진 특성
참조자의 주소
#include <iostream>
int main()
{
int a = 10;
int &aRef = a;
int b = a;
std::cout << "a address : " << &a << '\n' \
<< "b address : " << &b << '\n' \
<< "a reference : " << aRef << '\n' \
<< "a reference address : " << &aRef << '\n';
}
a address : 0x7ffeea49f8ac
b address : 0x7ffeea49f89c
a reference : 10
a reference address : 0x7ffeea49f8ac
- a의 주소 &a와 &aRef는 똑같다. 똑같이 a를 대입한 b의 주소는 a의 주소와 다르다. a의 별칭을 만드는 데 메모리를 할당하지 않는다는 특징이 있다.
함수 인자로 사용되는 참조자
#include <iostream>
void mV1(int x1) {
x1 = 42; // x는 함수 내에서 복사된 값
std::cout << "x1 address : " << &x << '\n';
}
void mV2(int& x2) {
x2 = 42;
std::cout << "x2 address : " << &x << '\n';
}
int main() {
int value = 10;
std::cout << "value address : " << &value << '\n';
mV1(value);
// value = 10
mV2(value);
// value = 42
return 0;
}
value address : 0x7ffee25618a8
x1 address : 0x7ffee256188c
x2 address : 0x7ffee25618a8
- 잘 알고 있듯이, c에서 value를 수정하려면 주소를 보낸 후 역참조하여 수정했어야 한다. 값을 복사하여 보내기 때문이다. 주소를 찍어보면 알 수 있다. 새로운 변수를 만들어 대입한 것처럼 두 값의 주소가 다르다(value, x1). 참조자를 사용하면 그러지 않아도 된다. 주소가 그대로 전달된다.
레퍼런스를 리턴하는 함수
참조자에 대해 좋은 글들이 많아서 참조자를 아주 자세히 다루진 않으려 했는데, 이건 좀 재밌어서 굳이 글로 남긴다.
int &aRef = a;처럼 참조자를 선언했던 것처럼, 함수의 반환값으로 참조 값을 사용할 수 있다.
case 1, 임시 값(지역 변수의 참조값)을 레퍼런스에 대입
int& function() // 반환값으로 참조
{
int a = 2;
return a;
}
int main()
{
int b = function(); //
b = 3;
return 0;
}
function()은 a를 가리키는 어떤 참조값인데, a는function()함수가 반환되어 스코프가 function 함수를 벗어나게 되면 메모리에서 사라지고,function()은 졸지에 가리키는 대상을 잃어버리게 된다. 이렇게 가리키는 값을 잃어버린 참조자를 댕글링 레퍼런스라고 한다.사실상 아래와 같다.
int& ref = a;
// 근데 a 가 사라짐
int b = ref; // !!!
- 댕글링 레퍼런스를 변수 b에 대입하려고 하기 때문에 오류가 발생한다.
case 2, 임시 값이 아닌 값(외부 변수의 레퍼런스 값)을 레퍼런스에 대입
int& function(int& a) {
a = 5;
return a;
}
int main() {
int b = 2;
int c = function(b);
return 0;
}
- 이건 좀 다르다. a는 b의 참조를 받은 값이기 때문에,
function()이 리턴되어도 main에 계속 남아있을 수 있다. 이 경우 오류가 발생하지 않는다.
case 3, 참조가 아닌 값을 반환하는 함수(임시 값)를 참조자에 대입
int function() {
int a = 5;
return a;
}
int main() {
int& c = function();
return 0;
}
- 이번엔
function()은 값을 반환하지만, 이 값을 참조자에 넣는다. 이 경우도 a는function()함수가 반환되면서 메모리에서 사라지기 때문에int& c에 대입할 수 없다. 이 코드는 사실상 맨 위에int b = function();,int& function() { int a = 5; return a; }(case 1)코드랑 다를게 거의 없다. 라인이 끝나면 사라지는 메모리 임시값(r value)을 참조할 수는 없다.
case 4, 상수 참조자의 대입
#include <iostream>
int function() {
int a = 5;
return a;
}
int main() {
const int& c = function();
std::cout << "c : " << c << std::endl;
return 0;
}
- 상수 참조자는 c++의 중요한 예외 규칙이다. 바로 위에서 임시값을 참조자에 대입할 수 없다고 말했는데, 상수 참조자는 예외다. c++는 임시 값의 수명을 연장하기 위해 상수 참조를 사용할 수 있도록 허용한다. 이것은 임시 값을 함수 호출에서 반환하거나 다른 표현식에서 사용할 때 유용하다고 한다. 예를 들어 아래와 같이 사용할 수 있다.
const int &aRef = 10;
// aRef는 10에 대한 상수 참조로 연장된 임시값을 가리킴
int x = aRef; // aRef의 값, 즉 10을 x에 복사
레퍼런스를 리턴하는 함수 정리
- 사이트에 올라와 있는 잘 정리된 표다.
| 함수에서 값 리턴 | 함수에서 참조자 리턴 | |
|---|---|---|
| 값 타입으로 받음 (int a = f()) | 값 복사됨 | 값 복사됨. 다만 지역 변수의 레퍼런스를 리턴하지 않도록 주의 |
| 참조자 타입으로 받음 (int& a = f()) | 컴파일 오류 | 가능. 다만 마찬가지로 지역 변수의 레퍼런스를 리턴하지 않도록 주의 |
| 상수 참조자 타입으로 받음 (const int& a = f()) | 가능 | 가능. 다만 마찬가지로 지역 변수의 레퍼런스를 리턴하지 않도록 주의 |
- 크게 신경써야 할 부분은 두 가지로 첫 번째로 참조자에 임시 값을 대입할 수 없다는 거고(“컴파일 오류”), 참조자를 반환할 때 임시 값을 반환하면 안된다는 것이다. 이거만 신경쓰면, 큰 어려움 없이 사용할 수 있을 것 같다.
이 외에 복수 참조자, 배열의 참조자 등에 대해서도 시간이 나면 알아보도록 하자.
ex03: Unnecessary violence
위에서 참조자에 대해 이야기 할때, 선언할 때 반드시 어떤 변수를 참조할 것인지 정해야 한다고 말한 바 있다. 이건 “반드시”다. 클래스가 생성될 때 반드시 생성자를 호출해야 한다는 것과 같은 맥락에서 이야기 할 수 있는데, 이러면 초기화에 대한 실수가 현저하게 적어지게 된다. 레퍼런스는 NULL을 허용하지 않는다. 반면 원시 포인터는 NULL을 가리킬 수 있다. 그 점을 알고 클래스를 만들면 되겠다.
Weapon 클래스 포인터를 갖는 HumanB
class HumanB
{
private:
Weapon *param;
std::string name;
public:
HumanB(std::string name);
// .. implementation
};
- Weapon 클래스 레퍼런스를 갖는 HumanA
class HumanA
{
private:
Weapon ¶m;
std::string name;
public:
HumanA(std::string name, Weapon &weapon);
// ... implementation
};
HumanB 생성 시 유의 사항
- 레퍼런스 열심히 잡다가 원시 포인터를 다루면 좀 숨막힐 때가 있다. HumanB를 생성할 때 아마
HumanB::HumanB(std::string name)
{
this->name = name;
this->param = NULL; // <-- 중요
}
이런 식으로 생성할 수 있을 것 같은데, 이때 param을 NULL로 설정하는 걸 잊으면 안된다! 생성자에서 손 안대면 Weapon* param은 NULL인거 아닌가요? 아니다. 이러면 C에서 하던 실수를 그대로 반복하는 거다. this->param = NULL을 생략하게 되면 HumanB의 Weapon* param은 NULL이 아니라 컴파일 타임에 스택에서 생성된 어떤 빈 const Weapon 클래스(문자열 리터럴을 생각해보자, 거의 동일한 경우이다)를 가리키게 된다(포인터 값도 존재한다). 물론 이 빈 Weapon 클래스의 type은 ““이다.(getType()이 빈 문자열을 반환한다) 왜 const냐. 아래를 보자.
HumanB::HumanB(std::string name)
{
this->name = name;
// this->param = NULL;
}
void HumanB::attack()
{
this->param->setType("Gun"); // 절름발이가 범인
std::cout << this->name << " attacks with their " << \
isArmed(this->param) << std::endl;
// isArmed()는 Weapon*의 NULL 여부를 확인하고, NULL이 아니라면
// getType()을 반환함
}
int main()
{
{
Weapon club = Weapon("crude spiked club");
HumanB jim("Jim");
jim.attack();
club.setType("some other type of club");
jim.attack();
}
}
- 메인문은 서브젝트 것을 그대로 갖고 왔다. 이러면 attack에서 무엇이 출력될까? Gun으로 attack 했다고 할까? 아니다.
zsh: bus error ./Weapon
// 문자열 리터럴(char* s = apple)의 인덱스를 변경할 때 발생하는 버스 에러와 동일한 종류의 버스 에러로 보인다.
- 끔찍한 버스 에러가 발생한다. 범인은
this->param->setType("Gun");이다. HumanB jim의 Weapon* param은 초기화되지 않아서 어떤 const Weapon을 가리키고 있는데, const Weapon을 수정하려고 하니 버스 에러가 발생하는 것이다. 사실 Weapon* param의 NULL 여부가 아니라 Weapon* param의getType()이 빈 문자열이냐 아니냐를 여부로 HumanB의 무기 유무를 판단하면 Weapon* param을 NULL로 초기화하지 않아도 이 문제에서 벗어날 수 있기는 한데, 별로 유쾌한 상황은 아니라고 생각한다. 원시 포인터를 다룰 땐 초기화를 신경쓰도록 하자.