Search

CPP Module 00

Created
2021/10/07
tag
42서울
42Seoul
CPP Module
All of C++98
Class
Constructor
Destructor
Standard I/O Stream

Subjects

1. ex00 (Megaphone)

Type Casting

cctype 헤더에 존재하는 std::toupper를 이용하게 될텐데, std::toupper의 반환 타입이 int이다 보니 std::cout을 이용하여 출력하면 숫자로 출력이 되는 것을 확인할 수 있다. 따라서 별도의 형 변환이 필요한데, C 언어 스타일의 (char)보다는 static_cast<char>를 이용하는 것이 좋다.

2. ex01 (PhoneBook)

Name Convention

CPP Module에서 처음으로 클래스를 이용하게 되는 부분이다. 서브젝트 안내 사항에 클래스 구현체에 대한 .cpp.hpp의 이름 형식이 PascalCase를 따른다는 점을 명심하자. 예를 들면, PhoneBook.cpp PhoneBook.hpp와 같다.

Whitespace

명령어에 대한 입력을 받을 때 정확히 EXIT, ADD, SEARCH에 대해서만 수행할지, 일반적인 명령어와 같이 앞 뒤 전후로 Whitespace를 정리하여 이용할 수 있도록 만들 것인지는 구현에 달려 있다. 개인적인 견해로는 후자에 대해서 구현을 하면서 std::ws를 써보는 것도 좋다고 생각한다.

failbit & badbit

std::getline 함수는 첫 번째 인자로 받은 std::istream 객체의 반환 타입을 그대로 유지하도록 되어 있다. 이 때 istream 객체가 조건문에 걸리게 되면, operator void*() const;라는 타입 변환 연산자를 거치게 된다. void * 타입은 NULL이면 false, 그렇지 않으면 true로 해석되기 때문에 컴파일러에 의해 bool 타입으로 해석될 수 있다. 즉, std::getline을 조건문에 걸어 NULL이 반환되는 경우에 대해서 오류 검증을 거치는 것이 좋다. std::getlineNULL을 반환하는 경우는 표준 입력의 상태를 나타내는 4가지 Flag Bit (goodbit, failbit, badbit, eofbit) 중에서 failbit, badbit에 걸릴 때이다.
failbit는 복구 가능한 오류가 발생했을 때 켜지고, badbit은 복구 불가능한 오류가 발생했을 때 켜진다.
그리고 SEARCH 명령어를 구현하다보면 std::getline이 아닌 std::cin을 통해서 입력을 받도록 구현하게 되는데, 이에 대해서도 위에서 언급한 것처럼 스트림의 failbitbadbit을 검출하여 처리하는 것이 적절하다. std::cin에 대해서는 std::cin.fail 함수 호출을 통해 failbit 혹은 badbit이 켜졌는지 확인할 수 있다.
std::getlinestd::cin을 통한 입력에서 제시된 failbitbadbit을 처리하는 방법에는 단순 종료와 스트림 복구 2가지 해결책이 있다. 이 중에서 후자의 경우에는 std::cin 객체의 Flag Bit들을 초기화할 수 있도록 std::cin.clear 함수를 호출하고, Flag Bit가 복구되었다면 스트림의 버퍼가 보유하고 있는 내용들을 std::cin.ignore 함수 호출을 통해 모두 지워야 한다.
std::cin.ignore 함수의 인자는 std::streamsizeint를 받는데, 두 매개 변수의 동작은 std::streamsize만큼을 int로 주어진 문자를 만날 때까지 삭제한다라고 볼 수 있다. 만일 얼만큼의 std::streamsize를 기재해야 할 지 모르겠다면, <limits>에서 std::numeric_limits 객체의 도움을 받을 수 있다. std::numeric_limits<std::streamsize>::max라는 static 함수를 통해 std::streamsize의 최댓값을 인자로 사용하면, std::streamsize는 고려하지 않고 int로 주어진 문자를 만날 때까지 삭제시킬 수 있다. std::cin에서 버퍼에 담긴 문자들의 구분은 Whitespace를 기반으로 동작하는데, 사용자에게 입력을 받을 때는 개행 문자를 기준으로 동작하게 구현했으므로 int로 주어진 문자는 개행 문자를 넣으면 된다.

3. ex02 (The Job Of Your Dreams)

STL & Implicit Conversion

Module 00의 마지막 문제는 tests.cpp의 코드를 컴파일 하여 실행했을 때, 주어진 .log 파일과 동일한 형태의 출력을 갖도록 만들면 된다. 이 과정에서 std::vector, std::pair 라는 ContainerContainer에서 사용할 수 있는 iterator와 같은 Modern C++의 영역에 대해서 마주하게 된다. 이에 대해서 아주 간략하게 짚고 넘어가보자.
iterator라고 함은 std::vector, std::pair와 같은 Container의 요소를 참조할 수 있도록 만든 객체이다. 의미에서 유추할 수 있듯이 이는 포인터의 동작과 유사하게 구현되어 있다. 즉, 기존에 배열에서 포인터를 이용하여 조작을 했던 것을 생각했을 때 Container에서는 iterator를 이용하여 조작하게 된다. 물론 Container에서도 포인터를 사용할 수 있으나, 그리 특별한 이유 없이는 iterator를 쓰는 것이 권장된다. iterator의 사용 목적은 여러 자료구조들을 구현한 Container들의 연산을 일반화 하는데 있다. 예를 들어, 배열의 다음 요소를 참조하기 위해선 ++을 이용하면 되지만, 리스트의 다음 요소를 참조하기 위해선 ++ 보다는 직접 구현한 함수를 이용해야 한다. 하지만 iterator를 이용하면 iterator에 구현된 기능을 통해 두 경우 모두 ++로 다음 요소를 접근할 수 있다. 이는 단순히 연산에만 한정되는 것이 아니라, STL<algorithm> 전역에 해당되어 강력한 기능들을 일반화된 코드로 이용할 수 있다.
std::pair<>로 묶인 2개의 template 인자를 타입으로 하여, firstsecond를 갖는다. 예를 들어, std::pair<int, int>(3, 5)라고 하면 firstsecond 모두 int 타입으로 볼 수 있으며, first3 그리고 second5가 된다.
std::vector동적 할당을 이용한 배열이라고 이해하면 된다. 따라서 동적 할당을 이용하므로 배열과 달리 한 번 사이즈가 정해졌다고 해서 크기를 변경할 수 없는 것은 아니다. std::vector 내에는 여러 생성자가 존재하는데, 그 중에서도 특정 범위를 iterator로 받는 생성자가 있다. 이를 Range Constructor라고 한다. 특정 범위의 요소를 그대로 옮겨와서 vector의 요소로 둔다. 이 정도 설명이면 주어진 tests.cpp를 이해하는데 크게 어려움이 없다.
Account::t라고 쓰인 것은 typedef를 이용한 Aliasing에 불과한데, 그냥 Account라고 이해해도 무방하다. 초기에 주어진 2개의 typedef는 각각 std::vector<Account>, std::vector<int>를 의미하고, 마지막 typedefstd::vector<Account>iteratorstd::vector<int>iterator를 쌍으로 갖는 std::pair에 대한 Aliasing이다.
이후에 각 개행 문자로 구분된 3개의 문단은 각각 std::vector<Account>의 초기화, std::vector<int> 타입인 Deposit의 초기화, std::vector<int> 타입인 Withdrawal의 초기화가 된다. 3개의 Container 초기화는 모두 이전에 언급했던 Range Constructor를 통해서 이뤄지는데, 마지막 2개의 초기화부터 먼저 살펴보자.
DepositWithdrawal값은 8개의 요소를 보유하고 있는 dw라는 배열로 주어진다. 이 때 std::vector<int> 타입인 depositswithdrawals의 초기화는 (d, d + d_size)(w, w + w_size)로 주어진 것을 볼 수 있는데, 이들은 배열의 시작 요소를 참조하는 포인터와 배열의 끝 요소를 참조하는 포인터가 사용된 것이다. 포인터는 iterator가 아님에도 Range Constructor에서 사용되었는데, 이는 포인터가 iterator로 해석되기에 충분하기 때문이다. 따라서 각 배열에 존재하는 int 타입의 모든 요소들이 int 타입을 요소로 갖는 depositswithdrawals에 그대로 복사된다.
당연히 포인터가 iterator와 동일하다는 것은 아니고, iterator의 개념이 포인터의 Semantic을 일반화하여 구현되었기 때문에 위와 같은 동작이 가능한 것이다. 모든 Containeriterator가 포인터의 모든 연산을 지원하는 것은 아니므로 주의해야할 점이 있다는 것을 알아두고, 이와 관련해서는 iterator의 5개 Category에 대해서 찾아보는 것을 권장한다.
Account 타입의 요소들을 갖고 있는 accounts의 초기화는 위에서 제시된 depositswithdrawal 처럼 Range Constructor로 초기화 되는 부분까지는 동일하다. 이전 생성자 호출은 int → int 여서 타입이 서로 맞지만, accounts의 경우에는 int → Account인데 tests.cpp의 코드가 잘못된 것이 아닌가 싶은 의문이 들 수 도 있다. 하지만 이는 지극히 정상적인 구문이다. 컴파일러는 int 타입의 인자를 받아서 Account를 만들 수 있는 경로를 탐색하는데, Account.hpp에서 int 타입의 인자를 받아서 Account 객체를 생성하는 생성자 함수를 이용하게 된다. 이와 같은 변환도 Implicit Conversion이라고 본다. 따라서 Range ConstructorAccount 타입을 정확하게 생성하여 넣지 않아도, Account로 생성되어 accounts의 요소로 추가되는 것이다.

std::for_each

<functional>에 존재하는 std::for_each 함수는 시작 지점과 끝 지점을 가리키는 iterator를 받아서 iterator로 참조되는 요소들에 대해 3번째 인자로 주어진 함수를 순차적으로 적용하게 된다. std::for_each의 3번째 인자로 줄 수 있는 함수의 Signaturevoid(T&)이다. 이 때 tests.cpp에서는 std::for_each의 인자를 std::mem_fun_ref로 특정 객체의 멤버 함수를 Wrapping하여 제공한 것을 볼 수 있는데, std::mem_fun_ref 객체는 Deprecated 되었다는 것을 알아두자. 이는 std::functionstd::bind를 통해서 동일하게 수행할 수 있는데, 자세한 내용은 아래 링크를 참조하자.

delete

delete라는 키워드는 Free Space (자유 공간 - C++에서의 Heap을 지칭하는 말)에서 메모리를 해제하는 키워드로 익숙할 것이다. Modern C++에서는 클래스의 생성자 혹은 소멸자에 대해서 delete를 지원한다. 이 때 delete의 역할은 명시적으로 컴파일러에게 특정 생성자 혹은 소멸자를 사용하지 않을 것이라고 알리는데 있다. 하지만, C++98 에서는 생성자 혹은 소멸자에 대해서 delete 키워드를 지원하지 않기 때문에 기본 생성자를 호출하지 못하도록 막으려면 별도의 방법이 필요하다.
단순하게 기본 생성자를 작성하지 않으면 되지 않을까 하는 의문이 들 수도 있지만, C++98 기준으로 기본 생성자, 대입 연산자, 복사 생성자, 소멸자는 명시적으로 작성되지 않았다면 컴파일러에 의해서 자동으로 정의되기 때문에 여전히 기본 생성자를 호출할 수 있게 된다. 따라서 기본 생성자 호출을 막기 위해선 외부에서 접근하지 못하도록 해야하므로 private 영역으로 빼야 한다. 즉, Account.cpp에서 기본 생성자는 호출 되지 않는다는 점을 유의하여 코드를 작성하면 된다.
Modern C++에 오면서 Move Semantic이 생김과 동시에 이 때부터는 이동 생성자도 명시적으로 정의하지 않으면 자동으로 정의된다.

Destruction

코드를 완전히 작성 후 std::vectorAccount 소멸 순서를 확인하면 사뭇 다를 수도 있다. 예를 들어 Account를 0~7번을 추가했을 때, 일부 시스템에서는 0~7번 순서로 소멸할 수도 있고 그렇지 않을 수도 있다. 특히 Mac OS X에서는 0~7번을 추가했을 때 이의 역순으로 소멸하고, Ubuntu의 경우에는 0~7번을 추가했을 때 해당 순서를 그대로 유지하며 소멸된다. 이와 같이 시스템마다 소멸 순서가 다른 이유는 std::vector소멸자 구현이 시스템마다 다른데 있다.
그렇다면 소멸 순서가 다르기 때문에 .log 파일에서 명시된 순서대로 정확히 맞춰야 하는가 라는 의문에는 그렇지 않다라고 생각한다. 소멸자를 강제로 조작해서 내부의 소멸 순서를 바꾸는 것에는 어떤 의미도 부여될 수 없고, 어떤 것도 얻을 수 없다. 현재 허용이 된 것은 Account에 대한 구현일 뿐이고, 이 문제의 원인은 std::vector에 있기 때문이다. std::vector에서 발생하는 문제를 Account라는 내부 요소 상에서 조작하는 행위는 Undefined Behavior로 이어질 가능성이 매우 높다. 따라서 이와 같은 무의미한 코드 조작은 올바르지 않다.
그리고 개인적인 추측인데, CPP Module이 전체적으로 리뉴얼 되면서 서브젝트가 바뀌었다. 리뉴얼 전의 서브젝트의 ex02에는 Old Ubuntu Server라고 명시가 되어 있었고, Ubuntu는 위에서 언급된 것처럼 순차적인 소멸을 지원한다. 다만 리뉴얼 후에는 이 구문이 지워졌는데, 이 때 .log 파일은 리뉴얼 이전의 파일을 그대로 유지한 것으로 생각된다.

Chrono

<chrono>C++11부터 지원이 가능하므로 <ctime>time_t 그리고 struct tm을 이용하여 timestamp를 구성해야 하는 점에 주의한다.