Unifox C++ 보고서 (상)

저자

  • 선린인터넷고등학교 소프트웨어과, Unifox 10기 나정휘

제1장. C++과 표준 라이브러리

가. C ++에 추가된 각종 구문에 대한 간략한 개요

A. I/Ostream
I/Ostream에 있는 출력의 기본적인 내용은 매우 간단하다. 출력 스트림(output stream / ostream)은 데이터들에 대한 통로와 같다. 어떤 것이든 통로에 넣어주면 적절히 출력이 된다. std::cout은 사용자 콘솔 혹은 표준 출력에 해당하는 통로다. std::cout말고도 여러가지 다른 통로가 있다. 예를 들어 std::cerr은 오류 콘솔에 출력을 한다. << 연산자가 테이터를 통로에 넣어주는 역할을 한다. 출력 스트림을 사용하면 다양한 유형의 여러 데이터를 한 줄의 코드에서 연속적으로 통로로 보낼 수 있다. 다음 코드는 문자열을 출력한 뒤, 숫자 데이터와 다른 문자열을 출력하는 코드이다.

1
std::cout << "HelloWorld" << 1234 << "Other String" << std::endl;

std::endl은 줄의 끝(end-of-line)을 나타낸다. 출력 스트림이 std::endl을 만나게 되면, 통로에 들어있는 모든 데이터를 비운 뒤 다음 줄로 넘어간다. 다음 줄로 넘어가는 것을 나타낼 수 있는 또 다른 방법은 \n 문자를 쓰는 것이다. \n 문자는 이스케이프 문자(escape character)이며, 줄 바꿈을 나타내는 문자이다. 다음 표는 가장 대중적인 몇 가지의 이스케이프 문자를 정리해 놓은 표이다.

\n 줄 바꿈 (new line)
\r 캐리지 리턴 (carriage return)
\t 탭 (tab)
\\ 역 슬래시 (backslash character)
\\" 큰 따옴표 (quotation mark)

스트림은 사용자로부터 입력을 받는 데에도 쓰인다. 가장 간단한 방법은 >> 연산자를 사용해 입력 스트림(input stream/istream)을 사용하는 것이다. std::cin은 키보드를 이용해 사용자로부터 입력을 받는다.

B. namespace
namespace는 서로 다른 코드 조각 간의 이름 충돌 문제를 해결해준다. 예를 들어, 프로그래머가 foo()라는 함수를 포함한 코드를 작성했다고 가정해보자. 어느 날, 프로그래머가 foo()라는 이름의 함수를 갖고 있는 라이브러리를 사용하게 된다면, 이름 충돌 문제가 발생한다. 라이브러리의 함수 이름을 변경할 수 없다면 큰 어려움을 겪을 것이다. 이름이 정의된 컨텍스트를 정의할 수 있기 때문에 namespace가 이러한 문제를 해결해 줄 수 있다. namespace에 코드를 넣으려면 namespace 블록 안에 코드를 넣으면 된다. 다음 코드가 aa.h에 있다고 가정하자.

1
2
3
namespace asdf {
    void foo ();
}

asdf라는 namespace에 foo() 함수를 넣으면 다른 라이브러리에서 제공하는 foo()함수와 분리된다. namespace에 있는 foo()함수를 호출하기 위해서는 스코프 설정 연산자라고 불리는 :: 연산자를 사용하여 namespace를 함수 이름 앞에 추가해서 쓴다.

1
asdf::foo(); //asdf에 있는 foo함수 호출

asdf 라는 namespace에 속하는 코드를 namespace를 명시적으로 앞에 추가하지 않아도 동일한 namespace 내의 다른 코드를 호출할 수 있다. 이러한 암시적 namespace는 코드를 더 읽기 쉽게 만드는데 유용하다. using 지시문을 사용하여 namespace를 미리 지정할 수 있다. 이 지시문은 뒤에 나올 코드가 지정된 namespace의 이름을 사용하고 있다는 것을 컴파일러에 알린다.

1
2
3
4
5
6
7
#include <iostream>
#include "aa.h"
using namespace asdf;

int main () {
    foo ();
}

단일 소스 파일에 여러 개의 using 지시문을 포함할 수는 있지만, 이 방법을 남용하지 않도록 조심해야 한다. 동일한 이름을 포함하는 두 개의 namespace를 사용하는 경우에는 이름 충돌이 다시 발생하게 된다. 실수로 잘못된 namespace에 있는 함수를 호출하지 않도록 작동 중인 namespace를 기억해야 한다.
또한, 출력 스트림 예제로 썼던 코드에 있는 cout과 endl은 사실 std라는 namespace에 정의되어 있다. HelloWorld를 출력하는 코드는 다음과 같이 쓸 수 있기도 하다.

1
2
3
4
5
6
#include <iostream>
using namespace std;

int main () {
    cout << "HelloWorld" << endl;
}

using 지시문은 namespace내의 특정 항목을 참조하는 데에도 사용 가능하다. 예를 들어 사용하려는 std namespace의 유일한 부분이 cout인 경우에는 다음과 같이 참조할 수 있다.

1
using namespace std::cout;

다음 코드는 namespace를 추가하지 않고도 cout을 사용할 수 있지만, std namespace에 들어있는 다른 항목을 사용할 때에는 명시를 해줘야 한다.

1
2
using namespace std::cout;
cout << "HelloWorld" << std::endl;

D. alternative function syntax (새로운 함수 정의 문법)
C++은 여전히 C에서 설계된 함수 구문을 이용한다. 그동안 C++에는 많은 새로운 기능이 추가되어 이전 함수 구문에 많은 문제점이 노출되었다. C++ 11부터는 새로운 함수 정의 문법이 지원된다. 이 새로운 문법은 일반 함수에서는 그리 유용하지 않지만, 템플릿 함수의 반환 타입을 지정하는 데에는 매우 유용하게 쓰인다.
다음 예제는 새로운 함수 정의 문법을 보여준다. 이 예제에서의 auto 키워드는 새로운 함수 정의 문법을 사용하여 함수 프로토타입을 시작한다는 의미이다.

1
2
3
4
auto func (int i) -> int
{
    return i + 2;
}

함수의 반환 타입은 더 이상 시작 부분이 아닌 화살표 뒤에 배치된다. 다음 코드는 새로운 함수 정의 문법으로 선언된 함수의 호출이 이전과 동일하며, main함수도 해당 문법을 사용할 수 있음을 보여준다.

1
2
3
4
5
6
7
using namespace std;

auto main () -> int
{
 cout << func (3) << endl;
 return 0;
}
나. 스마트 포인터의 기초

메모리 문제를 방지하기 위해서는 일반적인 C-스타일 포인터보다는 스마트 포인터를 사용해야 한다. 스마트 포인터는 함수 종료와 같은 스코프 탈출 시 자동으로 메모리 할당을 해제한다.
C++에는 std::unique_ptr, std::shared_ptr, std::weak_ptr 총 세 가지의 스마트 포인터가 있으며 모두 헤더에 정의되어 있다. unique_ptr는 일반 포인터와 유사하지만, unique_ptr가 스코프를 벗어나거나 삭제될 때 자동으로 메모리 혹은 리소스를 해제한다. unique_ptr는 가리키고 있는 객체의 단독 소유권을 갖는다. unique_ptr에 C-스타일 배열을 저장할 수도 있다. unique_ptr를 만들려면 std::make_unique<> 를 사용하면 된다.
예를 들어 `Employee* anEmployee = new Employee;` 대신 `auto anEmployee = std::make_unique ();` 와 같이 쓸 수 있다.
make_unique는 C++14부터 사용할 수 있다. 컴파일러가 C++14를 지원하지 않는다면 unique_ptr를 다음과 같이 만들 수 있다.

1
std::unique_ptr<Employee> anEmployee (new Employee);

shared_ptr는 데이터의 분산된 소유를 허용한다. shared_ptr가 할당될 때마다 참조 카운트가 증가되어 데이터의 소유자가 하나 더 있음을 나타낸다. shared_ptr가 범위를 벗어나면 참조 카운트가 감소한다. 참조 카운트가 0이 되면 데이터의 소유자가 더 이상 없다는 것을 의미하여 포인터에 의해 참조되는 데이터는 메모리에서 해제된다. shared_ptr에는 배열을 저장할 수 없다. shared_ptr를 만들려면 std::make_shared<> 를 사용하면 된다. 다만, weak_ptr를 사용하여 shared_ptr의 참조 카운트를 변화시키지 않으면서 shared_ptr를 관찰할 수 있다.

제2장. C++ 문자열

가. C++ std::string 클래스에 대한 세부 정보
C++은 표준 라이브러리의 일부로 문자열을 훨씬 향상된 방식으로 구현한다. C++에서 std::string은 cstring헤더(C언어의 string.h)에 있는 함수와 동일한 많은 기능을 지원하고, 사용자를 위해 메모리 할당을 관리해주는 클래스이다. string클래스는 std namespace의 string헤더에 정의되어 있다.
C++ 문자열 클래스의 필요성을 이해하려면 C-스타일 문자열의 장단점을 고려해야 한다.

  • 장점
    • 기본적인 char타입 및 배열 구조를 이용해서 간단하다.
    • 가볍고, 제대로 사용하면 필요한 만큼의 메모리만 차지한다.
    • low-level이기 때문에 원시 메모리처럼 쉽게 조작하고 복사할 수 있다.
    • C 프로그래머가 쉽게 이해할 수 있다.
  • 단점
    • 일급 객체를 조작하기 위해서 많은 노력이 필요하다.
    • 메모리 버그에 취약하다.
    • C++의 객체 지향 특성을 이용하지 않는다.

C++ string은 클래스임에도 불구하고 거의 기본적인 타입인 것처럼 취급할 수 있다. 사실, 단순한 타입이라고 생각할수록 좋다. 연산자 오버로딩을 통해 C++ 문자열은 C-스타일 문자열보다 훨씬 쉽게 사용할 수 있다. 예를 들어, + 연산자는 두 문자열을 연결해주는 연산으로 다시 정의된다.

1
2
3
4
string A = "12";
string B = "34";
string C;
C = A + B;

이 코드에서 C의 값은 “1234”가 된다.
또한, += 연산자도 오버로딩이 되어있어 문자열을 더 쉽게 더할 수 있게 해준다.

1
2
3
string A = "12";
string B = "34";
A += B;

이 코드에서는 A의 값이 “1234”가 된다.
C-스타일 문자열의 또 다른 문제는 == 연산자를 이용하여 비교를 할 수 없다는 것이다. 다음 코드를 보자.

1
2
3
char* a = "12";
char b [] = "12";
return (a == b);

위와 같이 비교를 하면 항상 false가 나온다. 위 코드에서 비교하는 대상은 문자열의 내용이 아닌 두 포인터(주소 값)을 비교한다. C-스타일 문자열을 비교하기 위해서는 다음과 같이 코드를 써야 한다.

1
2
3
char* a = "12";
char b [] = "12";
return !strcmp(a, b);

또한, C-스타일 문자열은 <, >, <=, >= 연산자를 이용해 비교를 못하기 때문에 strcmp()함수에서 -1, 0, 1을 반환하는 것을 이용해 두 문자열의 사전 순 관계를 비교한다. C++ 문자열에서는 ==, !=, < 등과 같은 연산자가 오버로딩 되어 있어 문자열을 비교할 수 있다. 각각의 문자들은 [] 연산자를 사용해 접근할 수도 있다.
다음 코드에서 볼 수 있듯이 문자열 확장(연결)으로 인해 메모리를 추가로 요구할 때 string 클래스에서 자동으로 처리해주기 때문에 메모리 오버런(memory overrun)은 더 이상 신경 쓸 필요가 없다.

1
2
3
4
5
6
7
8
string myString = "hello";
myString += ", there";
string myOtherString = myString;
if (myString == myOtherString) {
 myOtherString [0] = 'H';
}
cout << myString << endl; //hello, there
cout << myOtherString << endl; //Hello, there

위 코드에서 몇 가지 유의해야 할 사항이 있다. 첫 번째로, 문자열이 할당되고 크기가 조정이 되더라도 메모리 누수가 없다는 점이다. 또 다른 점은, 연산자들이 프로그래머가 원하는 대로 작동을 한다는 점이다. 예를 들어, = 연산자는 프로그래머가 원하는 대로 문자열을 복사한다. 만약 프로그래머가 배열 기반 문자열을 사용해왔다면, 이 기능이 그 프로그래머를 더 편하게 해 줄 것이다.

나. 원시 문자열 리터럴이란
원시 문자열 리터럴(raw string literals)은 개 행 문자, 큰 따옴표 등이 포함되어 있는 문자열을 \n이나 \” 와 같은 이스케이프 문자를 사용하지 않고 일반 텍스트로 처리할 수 있는 문자열 리터럴이다. 이스케이프 문자는 1장에 나와있다. 예를 들어, 일반적인 문자열 리터럴을 이용해 다음과 같이 작성하면 이스케이프 되지 않은 큰 따옴표가 포함 되어있기 때문에 컴파일 에러가 발생한다.

1
string str = "Hello "World"!"; //Error

일반 문자열 리터럴에서 큰 따옴표를 포함하기 위해서는 다음과 같이 해야 한다.

1
string str = "Hello \"World\"!";

원시 문자열 리터럴을 이용하면 따옴표를 이스케이프 처리하지 않고 표현이 가능하다. 원시 문자열 리터럴은 R”( 로 시작해 )” 로 끝난다.

1
string str = R"(Hello "World"!)";

원시 문자열은 여러 줄을 차지할 수도 있다. 예를 들어, 일반 문자열 리터럴을 이용해 다음과 같이 작성하면 일반 문자열 리터럴은 여러 줄에 걸쳐 존재할 수 없기 때문에 컴파일 에러가 발생한다.

1
2
string str = "Line 1
Line 2 with \t"; //Error

하지만, 원시 문자열을 이용하면 다음과 같이 작성이 가능하다.

1
2
string str = R"(Line 1
Line 2 with \t)";

또한, 원시 문자열 리터럴을 사용하면 \t문자가 실제 탭(tab)문자로 바뀌지 않고 그대로 저장이 된다. 그러므로 str을 콘솔에 출력하면 다음과 같이 나온다.

1
2
Line 1
Line 2 with \t

원시 문자열 리터럴은 )” 로 끝나기 때문에 문자열 중간에 )” 를 포함할 수 없다. 예를 들어, 다음 코드는 문자열 중간에 )” 가 포함되어 있기 때문에 유효하지 않다.

1
string str = R"(The characters )" are embedded in this string)"; //Error

)” 가 포함된 문자열이 필요하다면, 다음과 같은 확장된 원시 문자열 리터럴이 필요하다.

1
R"d-char-sequence(r-char-sequence)d-char-sequence"

r-char-sequence는 실제 원시 문자열이다. d-char-sequence는 원시 문자열 리터럴의 시작과 끝 부분에서 동일해야 하는 선택적 구분 기호 시퀀스이다. 이 구분 기호 시퀀스는 최대 16자까지 사용 가능하다. 이 구분 기호 시퀀스는 원시 문자열 리터럴에서 나타나지 않는 시퀀스로 선택해야 한다. 위에 있는 예제는 구분 기호 시퀀스를 이용하여 다시 작성할 수 있다.

1
string str = R"-(The characters )" are embedded in this string)-";

제3장. 클래스와 객체의 사용

가. 메소드와 멤버 데이터로 클래스 작성
클래스에는 여러 멤버가 있을 수 있다. 멤버에는 멤버 함수(즉, 메소드, 생성자 또는 소멸자)와 데이터 멤버 라고도 하는 멤버 변수가 있다. 다음 두 줄의 코드는 각각 멤버 함수를 선언하는 방법을 보여준다.

1
2
void setValue (double inValue);
double getValue () const;

그리고 다음 코드는 멤버 변수를 선언하는 방법이다.

1
double mValue;

클래스는 멤버 함수와 멤버 변수들을 정의한다. 클래스는 특정 인스턴스에만 적용된다. 이 규칙의 유일한 예외는 정적(static) 멤버이다. 클래스는 개념을 정의한다. 따라서 위 코드처럼 mValue라는 멤버 변수가 선언된 클래스의 각 개체들은 mValue에 대한 고유한 값을 갖고 있다. 멤버 함수의 구현은 모든 개체에서 공유된다. 클래스에는 원하는 수의 멤버 함수와 멤버 변수를 포함할 수 있다. 멤버 변수는 멤버 함수와 동일한 이름을 갖지 못한다.

나. 메소드와 멤버 데이터 접근 제어
클래스의 모든 멤버들은 public, private, protected 세 가지 접근 지정자(액세스 지정자) 중 하나의 대상이 된다. 접근 지정자는 다음 접근 지정자가 나오기 전까지 모든 멤버 선언에 적용된다. 디폴트(기본) 접근 지정자는 private이며, 첫 번째 접근 지정자가 나오기 전까지 지속된다. 다음 코드의 SpreadsheetCell 클래스에서 setValue와 mValue의 접근 지정자는 private이며, getValue의 접근 지정자는 public이다.

1
2
3
4
5
6
7
class SpreadsheetCell {
    void setValue (double inValue); //private access
    public:
        double getValue () const;
    private:
        double mValue;
};

C++에서는 구조체도 클래스처럼 멤버 함수(메소드)를 가질 수 있다. 유일한 차이점은 구조체의 기본 접근 지정자는 public이고, 클래스의 기본 접근 지정자는 private이라는 점이다. 그러므로 SpreadsheetCell 클래스는 구조체를 이용해 다음과 같이 작성할 수도 있다.

1
2
3
4
5
6
struct SpreadsheetCell {
    double getValue () const; // public access
    private:
        void setValue (double inValue);
        double mValue;
};

다음 표에는 세 가지 접근 지정자의 의미가 나와있다.

접근 지정자 의미 용도
public 어디서든 public 멤버에 접근 가능 클라이언트에서 사용할 동작, private혹은 protected 멤버에 대한 접근
private 클래스의 멤버 함수만 private 멤버에 접근 가능, 파생 클래스의 멤버 함수에서는 접근 불가 모든 것, 특히 멤버 변수는 기본적으로 private여야 한다.
파생 클래스에서의 접근을 허용할 때에는 protected인 setter와 getter를 제공하고, public인 setter와 getter를 제공해 클라이언트에서 접근하게 할 수 있다.
protected 해당 클래스와 파생 클래스의 멤버 함수에서만 protected 멤버에 접근 가능 클라이언트에서 사용하지 않기를 원하는 helper메소드

다. 메소드 정의
앞에서 정의한 SpreadsheetCell클래스는 클래스의 객체를 만드는 데에는 충분하다. 하지만 setValue나 getValue메소드를 호출하려고 하면 Linker가 해당 메소드가 정의되지 않았다고 한다. 그 이유는 클래스 정의가 메소드의 프로토타입(prototype)을 지정하지만 구현을 정의하지는 않기 때문이다. 일반적인 함수의 프로토타입과 구현을 모두 정의하는 것과 같이 메소드도 프로토타입과 구현을 정의해주어야 한다. 클래스의 정의는 메소드의 정의보다 앞에 와야 한다. 일반적으로 클래스의 정의 부분은 헤더 파일(.h 파일)에 들어가고, 메소드의 정의는 해당 헤더를 포함하는 소스 파일(.cpp 파일)에 들어간다. 다음은 SpreadsheetCell 클래스에 있는 두 가지 메소드의 정의 방법이다.

1
2
3
4
5
6
7
8
#include "SpreadsheetCell.h"

void SpreadsheetCell::setValue (double inValue) {
    mValue = inValue;
}
double SpreadsheetCell::getValue () const {
    return mValue;
}

클래스 이름 뒤에 두 개의 콜론(:)이 각 메소드 앞에 온다.

1
void SpreadsheetCell::setValue (double inValue)

이 구문은 컴파일러에 setValue메소드의 정의는 SpreadsheetCell클래스의 일부라고 알려준다.
참고로, 메소드를 정의할 때에는 접근 지정자를 반복하지 않는다.

라. 다른 메소드 호출
클래스의 메소드를 다른 메소드 내부에서 호출할 수 있다. SpreadsheetCell클래스를 예시로 들어보자. 실제 스프레드 시트 응용 프로그램은 셀에 텍스트와 숫자를 허용한다. 사용자가 텍스트 셀을 숫자로 해석하려고 하면 스프레드 시트에서 텍스트 데이터를 숫자로 변환하려고 한다. 만약 텍스트가 유효한 숫자를 나타내지 않는다면 셀 값이 무시된다. 이 프로그램에서는 숫자가 아닌 문자열을 0을 반환한다. 다음 코드는 텍스트 데이터를 지원하는 SpreadsheetCell 클래스 정의를 위한 첫 번째 단계이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <string>
class SpreadsheetCell {
    public:
        void setValue (double inValue);
        double getValue () const;
        void setString (const std::string& inString);
        const std::string& getString () const;
    private:
        std::string doubleToString (double inValue) const;
        double stringToDouble (const std::string& inString) const;
        double mValue;
        std::string mString;
};

이 클래스는 텍스트와 숫자 데이터를 모두 저장한다. 클라이언트가 데이터를 문자열로 생성하면 데이터는 double형(실수형)으로 변환되고 double형은 문자열로 변환이 된다. 텍스트가 유효한 숫자가 아니라면 double값은 0이 된다. 이 클래스의 정의는 셀의 텍스트 표현을 설정하고 double형과 문자열을 서로 변환해주는 메소드를 보여준다. 이 메소드는 문자열 스트림(string stream)이라는 것을 사용한다. 다음 코드는 모든 메소드의 구현이다.

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
#include "SpreadsheetCell.h"
#include <iostream>
#include <sstream>
using namespace std;

void SpreadsheetCell::setValue (double inValue) {
    mValue = inValue;
	mString = doubleToString(mValue);
}

double SpreadsheetCell::getValue () const {
	return mValue;
}

void SpreadsheetCell::setString (const string& inString) {
	mString = inString;
	mValue = stringToDouble(mString);
}

const string& SpreadsheetCell::getString () const {
	return mString;
}

string SpreadsheetCell::doubleToString (double inValue) const {
	ostringstream ostr;
	ostr << inValue;
	return ostr.str ();
}

double SpreadsheetCell::stringToDouble (const string& inString) const {
	double temp;
	istringstream istr(inString);
	istr >> temp;
	if (istr.fail() || !istr.eof()) {
	return 0;
	}
	return temp;
}

마. 힙과 스택에 있는 객체 사용
다음 코드는 스택에 SpreadsheetCell 개체를 생성하고 사용하는 코드이다.

1
2
3
4
5
SpreadsheetCell myCell, anotherCell;
myCell.setValue(6);
anotherCell.setString("3.2");
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;

변수 자료형이 클래스 이름이라는 점을 제외하고는 단순 변수를 선언한 것처럼 개체를 생성한다. myCell.setValue(6)에서 나오는 . 을 도트(Dot, 점)연산자라고 부른다. 이 연산자를 통해 개체에 대한 메소드를 호출할 수 있다. 개체에 public 멤버 변수가 있다면 도트 연산자를 이용해 해당 멤버 변수에 접근할 수도 있다. 하지만 public 멤버 변수는 권장하지 않는다.
이 프로그램의 출력은 다음과 같다.

1
2
cell 1: 6
cell 2: 3.2

또한, new 키워드를 사용하여 개체를 동적으로 할당할 수도 있다.

1
2
3
4
5
SpreadsheetCell* myCellp = new SpreadsheetCell ();
myCellp->setValue (3.7);
cout << "cell 1: " << myCellp->getValue () << " " << myCellp->getString () << endl;
delete myCellp;
myCellp = nullptr;

힙에 개체를 생성하면, 화살표 연산자(->)를 통해 해당 개체의 멤버에 접근할 수 있다. 화살표 연산자는 참조( * )연산자와 멤버 접근( . )연산자를 결합한 것이다. 이 두 연산자를 대신 사용할 수도 있겠지만, 귀찮을 것이다.

1
cout << "cell 1: " << (* myCellp).getValue() <<  " " << (* myCellp).getString() << endl;

메모리 문제의 발생을 방지하기 위해서 다음과 같이 스마트 포인터를 사용하는 것이 좋다.

1
2
3
auto myCellp = make_unique<SpreadsheetCell>();
myCellp->setValue (3.7);
cout << "cell 1: " << myCellp->getValue () << " " << myCellp->getString () << endl

바. 객체의 생명 주기
객체의 생명주기는 생성, 할당, 소멸 세 가지의 활동을 포함한다. 객체를 생성, 할당, 제거하는 방법과 시기, 그리고 이러한 동작을 사용자가 지정하는 방법을 이해하는 것이 중요하다. 객체는

  1. 객체가 스택에 있다고 선언된 시점이나
  2. new, new[ ], 혹은 스마트 포인터를 사용하여 공간을 명시적으로 할당할 때

생성된다. 객체가 생성이 되면 해당 객체가 포함하고 있는 다른 모든 객체도 함께 생성된다. 예시로 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
#include <string>

class MyClass {
    private:
        std::string mName;
};
int main () {
    MyClass obj;
    return 0;
}

MyClass에 포함된 string객체는 main함수 안에서 obj가 생성된 시점에 생성이 되고, 자신을 포함한 객체, 즉 obj가 소멸될 때 같이 없어진다.

사. 객체의 생성자
생성자 메소드의 이름은 클래스 이름과 동일하고 반환 값이 없으며 매개 변수는 있거나 없을 수 있다. 매개 변수가 없는 생성자를 기본 생성자(default constructor)라고 한다. 특정 상황에서는 기본 생성자를 제공해야 하는 경우가 생기며, 제공하지 않는 경우 컴파일 에러가 발생한다. 다음은 SpreadsheetCell클래스에 생성자를 추가하는 첫 번째 시도이다.

1
2
3
4
1
2
3
4
class SpreadsheetCell {
    public:
        SpreadsheetCell (double initialValue);
};

일반적인 메소드처럼 구현도 제공해야 한다. 생성자의 경우, 다음과 같이 구현을 작성한다.

1
2
3
SpreadsheetCell::SpreadsheetCell (double initialValue) {
    setValue (initialValue);
}

SpreadsheetCell생성자는 SpreadsheetCell클래스의 멤버이므로 생성자 이름 전에 SpreadsheetCell::을 붙여야 한다. 위 생성자는 setValue를 호출하여 숫자 및 텍스트 표현을 설정한다.
생성자를 사용하면 개체가 생성되고 해당 값이 초기화된다. 스택 기반 할당과 힙 기반 할당 모두 생성자를 사용할 수 있다.
스택에 SpreadsheetCell객체를 할당할 때에는 다음과 같은 생성자를 사용한다.

1
2
3
SpreadsheetCell myCell (5), anotherCell (4);
cout << "cell 1: " << myCell.getValue() << endl;
cout << "cell 2: " << anotherCell.getValue() << endl;

다만, SpreadsheetCell생성자를 명시적으로 호출하면 안된다. 예를 들어, 다음과 같은 코드를 사용하면 안된다.

1
SpreadsheetCell myCell.SpreadsheetCell(5);

마찬가지로, 생성자를 나중에 호출할 수도 없다. 다음 코드도 잘못된 코드이다.

1
2
SpreadsheetCell myCell;
myCell.SpreadsheetCell(5);

에 SpreadsheetCell객체를 동적으로 할당할 때에는 다음과 같은 생성자를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
auto smartCellp = make_unique<SpreadsheetCell> (4);
//다른 작업
//스마트 포인터를 이용하면 소멸시킬 필요가 없다.

//혹은 다음과 같이 사용할 수도 있지만 권장하지 않는다.
SpreadsheetCell* myCellp = new SpreadsheetCell (5);
SpreadsheetCell* anotherCellp = nullptr;
anotherCellp = new SpreadsheetCell (4);
//다른 작업
delete myCellp; myCellp = nullptr;
delete anotherCellp; anotherCellp = nullptr;

스마트 포인터를 사용하지 않고 객체를 동적으로 할당한 경우에는 삭제를 해야 하는 것을 기억하자.
클래스에 둘 이상의 생성자를 제공할 수 있다. 모든 생성자는 동일한 이름을 갖지만 서로 다른 개수의 매개 변수 혹은 매개 변수 타입을 사용해야 한다. C++에서 이름이 같은 함수가 두 개 이상이 경우 컴파일러는 호출 구문의 유형과 일치하는 매개 변수 유형의 함수를 선택한다. 이를 메소드 오버로딩이라 부르고, 뒤에 자세한 설명이 나온다. SpreadsheetCell클래스에는 두 개의 생성자를 사용하는 것이 좋다. 하나는 초기 값을 double값으로 취하고, 다른 하나는 초기 값을 문자열로 취하는 것이 좋다. 새로운 클래스의 생성자 정의 부분은 다음과 같다.

1
2
3
4
5
class SpreadsheetCell {
    public:
        SpreadsheetCell (double initialValue);
        SpreadsheetCell (const std::string& initialValue);
};

다음은 두 번째 생성자의 구현 부분이다.

1
2
3
SpreadsheetCell::SpreadsheetCell (const string& initialValue) {
    setString(initialValue);
}

다음은 서로 다른 두 개의 생성자를 사용하는 코드이다.

1
2
3
4
5
6
SpreadsheetCell aThirdCell("test"); //문자열을 이용한 생성자
SpreadsheetCell aFourthCell(4.4); //double을 이용한 생성자
auto aThirdCellp = make_unique<SpreadsheetCell>("4.4"); //문자열을 이용한 생성자
cout << "aThirdCell: " << aThirdCell.getValue() << endl;
cout << "aFourthCell: " << aFourthCell.getValue() << endl;
cout << "aThirdCellp: " << aThirdCellp->getValue () << endl;

생성자가 여러 개인 경우, 하나의 생성자를 다른 생성자를 이용해 구현하려고 할 수도 있다. 예를 들어, 몇몇은 다음과 같이 문자열 생성자에서 double생성자를 호출하려 할 것이다.

1
2
3
SpreadsheetCell::SpreadsheetCell (const string& initialValue) {
    SpreadsheetCell(stringToDouble(initialValue));
}

말이 되는 것처럼 보인다. 이 코드는 컴파일, 링크, 실행 모두 되지만, 사용자가 예상하는 대로 동작하지는 않는다. SpreadsheetCell 생성자에 대한 명시적 호출은 실제로 SpreadsheetCell 타입의 이름이 없는 새로운 임시 개체가 생성이 되고, 초기화 할 개체의 생성자를 호출하지 않는다.
기본 생성자는 매개 변수가 없는 생성자이다. 0-매개 변수 생성자(0-argument constructor) 라고 부르기도 한다. 기본 생성자를 사용하면 클라이언트에서 초기 값을 지정하지 않아도 멤버 변수에 초기 값을 제공할 수 있다.
기본 생성자가 필요한 경우를 보자. 개체의 배열의 경우, 개체의 배열을 생성하는 작업은 두 가지 과정을 거친다. 모든 개체를 연속된 메모리 공간에 할당하고, 각 개체에 대해 기본 생성자를 호출한다. C++에서는 배열 선언 코드에 다른 생성자를 직접 호출하도록 하는 구문을 제공하지 않는다. 예를 들어, SpreadsheetCell 클래스의 기본 생성자를 정의하지 않으면 다음 코드는 컴파일 되지 않는다.

1
2
SpreadsheetCell cells[3]; //기본 생성자가 없으면 컴파일이 되지 않는다.
SpreadsheetCell* myCellp = new SpreadsheetCell[10]; //마찬가지로 컴파일 되지 않는다.

다음과 같은 구문을 사용하면 스택 기반 배열에서는 이러한 제약을 피할 수 있다.

1
SpreadsheetCell cells [3] = {SpreadsheetCell (0), SpreadsheetCell (23), SpreadsheetCell (41)};

그러나 클래스의 객체 배열을 만들 때에는 기본 생성자가 있는지 확인하는 것이 좋다. std::vector와 같은 STL 컨테이너에 클래스 개체를 저장할 때에도 기본 생성자가 필요하다. 그리고, 기본 생성자는 클래스의 개체를 다른 클래스 내에서 생성하는 경우에도 유용하게 쓰인다. 다음은 기본 생성자를 사용하는 SpreadsheetCell 클래스 정의의 일부이다.

1
2
3
4
class SpreadsheetCell {
    public:
        SpreadsheetCell();
};

다음은 기본 생성자의 구현 부분이다.

1
2
3
SpreadsheetCell::SpreadsheetCell () {
    mValue = 0;
}

mString의 경우, std::string은 기본 생성자가 자동으로 호출되기 때문에 빈 문자열로 초기화된다. 스택에서 기본 생성자는 다음과 같이 사용한다.

1
2
3
SpreadsheetCell myCell;
myCell.setValue(6);
cout << "cell 1: " << myCell.getValue() << endl;

힙에서 기본 생성자는 다음과 같이 사용하다.

1
2
3
4
auto smartCellp = make_unique<SpreadsheetCell> ();
/*SpreadsheetCell* myCellp = new SpreadsheetCell ();
SpreadsheetCell* myCellp = new SpreadsheetCell;
delete myCellp; myCellp = nullptr;*/

프로그래머가 생성자를 정의하지 않은 경우, 컴파일러가 자동으로 기본 생성자를 만든다. 이것을 Compiler-Generated Default Constructor라고 한다. 처음에 정의했던 SpreadsheetCell 클래스는 다음과 같았다.

1
2
3
4
5
6
7
class SpreadsheetCell {
    public:
        void setValue (double inValue);
        double getValue () const;
    private:
        double mValue;
};

이렇게 정의를 한다면, 기본 생성자를 선언하지는 않았지만 다음 코드는 완벽하게 작동한다.

1
2
1
2
SpreadsheetCell myCell;
myCell.setValue(6);

다음 정의는 double생성자를 선언했다는 점을 제외하고는 이전 정의와 똑같다. 여전히 기본 생성자를 명시적으로 선언하지 않았다.

1
2
3
4
1
2
3
4
class SpreadsheetCell {
    public:
        SpreadsheetCell (double initialValue);
};

위 정의를 사용하면 다음 코드는 더 이상 컴파일 되지 않는다.

1
2
1
2
SpreadsheetCell myCell;
myCell.setValue(6);

이유는, 만약 생성자를 선언하지 않았다면 컴파일러는 매개 변수가 필요 없는 생성자를 하나 생성하지만, 기본 생성자 혹은 다른 생성자를 명시적으로 선언한 경우에는 더 이상 컴파일러에서 기본 생성자를 생성하지 않기 때문이다.

아. 복사 생성자
C++에는 복사 생성자라는 특수한 생성자가 있어 다른 개체의 정확한 복사본인 개체를 만들 수 있다. 복사 생성자를 직접 작성하지 않으면 C++에서 원본 개체의 동등한 멤버 변수에서 새 개체의 각 멤버 변수를 초기화 하는 작업을 수행한다. 이 초기화 과정은 복사 생성자의 호출을 의미한다. 다음은 SpreadsheetCell 클래스의 복사 생성자에 대한 선언이다.

1
2
3
4
class SpreadsheetCell {
    public:
        SpreadsheetCell (const SpreadsheetCell& src);
};

복사 생성자가 원본 개체에 대해 참조를 한다. 다른 생성자들과 마찬가지로 값을 반환하지 않는다. 생성자 내부에서 원본 개체의 모든 멤버 변수를 복사해야 한다. 엄밀히 말하면, 복사 생성자에서 원하는 것은 무엇이든 할 수 있지만, 일반적으로 예상되는 행동을 따르고 원본 개체의 데이터를 새로운 개체에 복사하는 것이 좋다. 다음은 SpreadsheetCell 클래스의 복사 생성자 구현 부분이다.

1
2
SpreadsheetCell::SpreadsheetCell (const SpreadsheetCell& src) :
 mValue(src.mValue), mString(src.mString) { }

C++에서 함수에 인자를 전달하는 기본적인 방법은 pass-by-value(call-by-value)이다. 이는 함수 또는 메소드가 인자로 값 또는 개체의 복사본을 받는다는 것을 의미한다. 따라서 사용자가 개체를 함수나 메소드로 전달할 때마다 컴파일러에서 해당 개체를 초기화 하기 위해 복사 생성자를 호출한다. 예를 들어 SpreadsheetCell 클래스의 setString()메소드가 다음과 같다고 가정해보자.

1
2
3
4
5
6
7
8
void SpreadsheetCell::setString (string inString) {
    mString = inString;
    mValue = stringToDouble(mString);
}
void SpreadsheetCell::setString (string inString) {
    mString = inString;
    mValue = stringToDouble(mString);
}

다시 말하지만, C++ string은 기본적으로 제공되는 자료형이 아닌 클래스이다. 코드가 setString()을 호출할 때 inString 매개 변수는 복사 생성자의 호출로 인해 초기화된다. 복사 생성자의 인자로는 setString()에 전달한 문자열이 전달된다. 다음 코드에서는 setString()의 inString개체에 대해 string 복사 생성자가 호출된다.

1
2
3
SpreadsheetCell myCell;
string name = "heading one";
myCell.setString(name);

setString() 메소드가 종료되면 inString은 소멸된다. inString은 단지 name의 복사본이기 때문에 name은 온전히 남아있다.
함수나 메소드에서 개체를 반환할 때도 복사 생성자가 호출된다. 이러한 경우에는, 컴파일러가 복사 생성자를 통해 이름이 없는 임시 객체를 생성한다.
복사 생성자를 명시적으로 호출할 수도 있다. 한 개체를 다른 개체의 정확한 복사본으로 만드는 것은 종종 유용하다. 예를 들어, 다음과 같은 코드를 통해 SpreadsheetCell 개체의 복사본을 생성할 수 있다.

1
2
SpreadsheetCell myCell2(4);
SpreadsheetCell myCell3(myCell2);

(하) 에서 계속…