취미/개발서적

전문가를 위한 C++ - Chapter1

게임 개발 2024. 4. 27. 22:16

 

전처리 지시자
C++로 작성된 소스 코드를 프로그램으로 만드는 빌드 작업은 세 단계를 거친다.

 

 

1. 전처리 단계

 

전처리 단계에서는 소스 코드에 담긴 메타 정보를 처리한다.

 

 

2. 컴파일 단계

 

컴파일 단계에서는 소스 코드를 머신이 읽일 수 있는 오브젝트Object (목적) 파일로 변환한다.

 

3. 링크 단계

 

링크 단계에서는 변환한 여러 오브젝트 파일을 애플리케이션으로 엮는다.

 

지시자 (directive)란 전처리기에 전달할 사항을 표현하며,

#include <iostream>처럼 # 문자로 시작한다.

 

여기서 #include 지시자는  <iostream> 헤더 파일에 있는 내용을

현재 파일에서 사용할 수 있게 모두 가져오도록 전처리기에 지시한다.

 

헤더 파일은 주로 나중에 소스 파일에서 구현할 함수를 선언(declare)하는 용도로 사용된다.

이러한 함수 선언부(declaretion)는 그 함수의 호출 방식, 매개변수의 개수와 타입, 리턴 타입 등만

컴파일러에게 알려주고, 그 함수가 실제로 수행할 동작은 구현부 (정의부 definition)에 작성한다.

 

다시 말해 C++에서 선언은 확장자가 .h인 헤더 파일에 작성하고,

구현은 확장자가 .cpp인 소스 파일에 작성한다.

C#이나 자바 같은 프로그래밍 언어는 선언과 구현을 분리하지 않고 한 파일에 작성한다.

 

<iostream> 헤더 파일은 C++에서 제공하는 입력 및 출력 메커니즘을 선언한다.

 

 

전처리 지시자 기능 사용 예
#include [파일] 지정한 '파일'의 내용을
지시자 위치에 넣는다.
다른 곳에 정의된 함수를 사용할 목적으로
해당 함수의 선언문이 담긴 페더 파일을 가져온다.
#define [키] [값] 코드에서 '키'에 해당하는 부분을 모두 
'값'으로 지정한 내용으로 바꾼다.
C에서 주로 상숫값이나
매크로를 정의하는데 사용했다.
C++는 상수 및 매크로 정의에 대해
좀 더 개선된 메커니즘을 제공한다.
매크로는 자칫 위험할 수 있어서
사용할 때 주의해야 한다.
#ifdef [키]
#endif

#ifndef [키]
#endif
여기서 지정한 '키'가
#define 문으로 정의됐는지 여부에 따라
ifdef나 ifndef로 묶인 코드 블록을
포함시키거나 제외한다.
참고로 여기서는 ifdef가
'...가 정의돼 있다면 (if defined)을
ifndef는
'...가 정의돼 있지 않다면
(if not definded)을 뜻한다.
주로 인클루드 문장이 중복해서 추가되는 것을
막는 용도로 사용한다.
#ifndef로 헤더 파일을 불러오면
먼저 '키' 값이 정의돼 있는지 확인 한 뒤,
없다면 #define 지시자로 그 '키'를 정의한다.
그리고
#endif 지시자로 헤더 파일 추가 구문을 닫는다.
이렇게 하면 같은 파일이 여러 번 추가되는 것을
방지할 수 있다.
#pragma [xyz] xyz에 대한 구체적인 동작은 
컴파일러마다 다르다.
주로 전처리 과정에서 이 지시자에 도달할 때
경고나 에러 메시지를
화면에 표시하는 용도로 사용한다.
 

 

#ifndef MYHEADER_H
#define	MYHEADER_H

// ... 헤더 파일에 담을 내용

#endif // !MYHEADER_H


#pragma once
// ... 헤더 파일에 담을 내용

 

만약 사용하는 컴파일러에서 #pragma once를 지원한다면 위나 아래나 같다.

 

 

네임 스페이스
네임 스페이스는 코드에서 이름이 서로 충돌하는 문제를 해결하기 위해 나온 개념

 

 

만약 네임스페이스를 적용한 foo()라는 함수를 호출하려면
::(스코프 지정 연산자)를 이용하여 함수 이름 앞에 네임스페이스를 붙인다.

 

mycode::foo();	// mycode 네임 스페이스에 정의된 foo()함수를 호출한다.

 

 

using namespace mycode;

int main()
{
	foo();
    return 0;
}

 

다음과 같이 mycode 네임스페이스 블록 안에서 접근할 때는

네임스페이스를 접두어로 붙이지 않아도 된다.

이렇게 네임스페이스를 생략하면 코드의 가독성을 좀 더 높일 수 있다.

또한 using 지시자를 사용하면 네임스페이스 접두어를 생략할 수도 있다.

컴파일러는 using 지시자를 보면

그 뒤에 나오는 문장부터는 using에 지정된 네임스페이스에 속하는 것으로 처리한다.

 

네임스페이스 안에 있는 특정한 항목만 가리키도록 using 문을 작성할 수도 있다.

예를 들어 std 네임스페이스에 cout만을 사용하고 싶다면,

다음과 같이 코드를 작성할 수 있다.

 

using std::cout;

cout << "Hello, World!" << std::endl;

 

 

헤더 파일 안에서는 절대로 using 문을 작성하면 안 된다.

그러면 그 헤더 파일을 인클루드하는 모든 파일에서 using 문으로 지정한 방식으로 호출해야 한다.

 

C++17에서 중첩된 네임스페이스를 좀 더 쉽게 사용할 수 있도록 개선했다.

중첩된 네임스페이스란 네임스페이스 안에 있는 네임스페이스를 말한다.

 

// C++17 이전 코드

namespace Networking{
	namespace FTP{
		/* ... */    
    }
}

namespace MyLibraties::Networking::FTP {
	/* ... */
}

 

네임스페이스 앨리어스를 시용하면 네임스페이스의 이름을 다르게 표현하거나

기존 이름을 좀 더 짧게 만들 수 있다.

 

namespace MyFTP = MyLibraries::Networking::FTP;

 

 

 

리터럴
리터럴은 코드에 표시한 숫자나 스트링과 같은 값을 의미한다.
C++는 다얀한 표준 리터럴을 제공한다.

 

쉽게 말해 리터럴이란 소스 코드의 고정된 데이터를 이용한다.

 

bool b      = true;         //불리언
int num1    = 100;          //10진수
int num2    = 0x11;         //16진수
int num3    = null;         //null
float num4  = 1.2f          //실수
double num5 = 0.11111;      //실수
char chr    = 'c';          //문자
string str  = "BlockDMask"; //문자열

 

즉 위의 코드를 기반으로 프로그래머가 직접 입력할 수 있는 값들이 존재한다.

해당 값들이 리터럴이다.

 

리터럴이란 소스코드에 하드코딩된 값을 말한다.

 

위의 코드를 보면 =의 오른쪽처럼 프로그래머가 직접 입력할 수 있는 값들이 존재한다.

 

 

++

Const와 리터럴

상수와 리터럴 둘 다, 변하지 않는 값(데이터)을 의미한다.

코드적으로 말하면, 상수는 소스코드의 변하지 않는 변수를 말한다.

 

상수는 한번 할당되면 변경할 수 없다.

 

리터럴은 변하지 않는 데이터로 우변에 쓰인다. (데이터 값 그 자체)

상수는 변하지 않는 변수로 좌변에 쓰인다.

 

상수는 메모리 위치(공간)이며, 메모리 값을 변경할 수 없다.


리터럴은 메모리 위치(공간) 안에 저장되는 값이다.

리터럴을 번역하면 문자 그대로 라는 의미다.

말 그대로 저장되는 값 그대로이다.

++

 

리터럴 표기법

 

리터럴 표기법이란, 변수를 선언함과 동시에 그 값을 지정해주는 표기법을 말한다.

 

 

변수 타입을 실행중에 바꿀 수 있는데 이를 캐스팅 (동적 형변환, 타입캐스팅)이라 한다.

C++에서 변수의 타입을 명시적으로 변환하는 방법은 세 가지이다.

 

명시적 형변환

float myFloat = 3.14f;
int i1 = (int)myFloat;					// 방법 1
int i2 = int(myFloat);					// 방법 2
int i3 = static_cast<int>(myFloat);		// 방법 3

 

방법2는 거의 사용되지 않고,

방법3은 좀 길지만 명확해서 이렇게 표현하는 것이 바람직하다.

 

이렇게 캐스팅하면 부동소수점수에서 소수점 아랫부분을 잘라낸 나머지 정수 부분만 남게된다.

문맥에 따라 변수의 타입이 강제로 캐스팅(강제 형변환)될 때도 있다.

 

암시적 형변환

float n1 = 3.14;
int n2 = n1;

 

 

변수를 자동으로 캐스팅할 때 데이터가 손실될 수 있다는 점에 주의하자.

명시적으로 캐스팅하지 않으면 경고 또는 에러 메시지를 발생시킨다.

 

 

포인터와 동적 메모리

 

 동적 메모리를 이용하면 컴파일 시간에 크기를 확장할 수 없는 데이터를 다룰 수 있다.

아주 단순판 프로그램이 아니라면 대부분 어떤 형태로든 동적 메모리를 사용한다.

 

스택과 힙

 

C++ 애플리케이션이 사용하는 메모리는 크게 스택과 힙으로 나뉜다.

스택은 테이블에 쌓아둔 접시에 비유할 수 있다.

제일 위에 놓인 접시는 프로그램의 현재 스코프를 표현하며,

주로 현재 실행 중인 함수를 가리킨다.

 

스택은 주로 쌓여있는 접시를 생각하면 쉽다.

현재 실행 중인 함수에 선언된 변수는 모두 최상단에 접시에 해당하는

최상단 스택 프레임의 메모리 공간에 담겨있다.

 

즉, foo()라는 함수가 실행된 상태에서

bar()라는 다른 함수를 호출하면 최상단 접시 위에 bar()라는 함수에 대한 접시,

즉 스택 프레임이 올라온다. foo()에서 bar()로 전달되는 매개 변수는 모두

foo()의 스택 프레임에서 bar()의 스택 프레임으로 복제된다.

 

스택 프레임은 각각의 함수마다

독립적인 메모리 공간을 제공한다는 점에서 굉장이 유용하다.

 

 

스택에 할당된 변수는

프로그래머가 직접 할당 해제(deallocate)할 필요 없이 자동으로 처리된다.

 

힙이란, 현재 함수 또는 스택 프레임과 완전히 독립적인 메모리 공간이다.

함수가 끝난 후에 도 그 안에서 사용하던 변수를 계속 유지하고 싶다면 힙에 저장한다.

힙은 스택보다 구조가 간결하다.

마치 비트 더미와 같다.

프로그램에서 원하는 시점에 언제든지 비트 더미 속에서 새로운 비트를 추가할 수 있고

기존에 있던 비트를 수정할 수 있다.

 

힙에 할당된 메모리 공간은 직접 할당 해제 (삭제) 해야한다.

힙은 스마트 포인터를 이용하지 않는 한 자동으로 할당 해제되지 않기 때문이다.

 

Tip. 변수를 선언하면 반드시 초기화 해주기

 

왜냐? 아직 값을 할당하지 않았기 때문에 포인터가 구체적으로 가리키는 대상이 없다

즉, 쓰레기 값을 가진 포인터는 어느 곳을 가리키는지 알 수 없기 때문에

반드시 초기화해야한다.

 

포인터가 가리키는 값에 접근하려면

포인터를 역참조해야한다.

 

역참조란?

포인터가 힙에 있는 실젯값을 가리키는 화살표를 따라가는 것이다.

 

동적으로 할당한 메모리를 다 쓰고 나면 delete 연산자로 그 공간을 해제해야 한다.

메모리를 해제한 포인터를 다시 사용하지 않도록

곧바로 변수의 값을 nullptr로 초기화 하는 것이 좋다.

 

++

포인터를 역참조하려면 반드시 메모리가 할당돼 있어야 한다.

널 포인터나 초기화하지 않은 포인터를 역참조하면

프로그램의 동작을 예측할 수 없게 된다.

 

프로그램이 곧바로 멈출 수도 있고,

계속 실행되지만 이상한 결과가 나올 수 있다.

++

 

포인터는 힙 뿐만아니라 스택과 같은 다른 종류의 메모리를 가리킬 수 있다.

원하는 변수의 포인터값을 알고싶다면

주소 참조 연산자인 &를 사용한다.

 

널 포인터 상수

 

C++11 이전에는 NULL이란 상수로 널 포인터를 표현했다.

NULL은 실제로 상수 0과 같아서 문제가 발생할 여지가 있다.

 

void func(char* str) { cout << "char* version" << endl; }
void func(int i) { cout << "int version" << endl; }


int main(void)
{
	// int 버전의 func() 호출
	func(NULL);

	// char* str func() 호출
	func(nullptr);

	return 0;

}

 

 

 

스마트 포인터

 

스마트 포인터를 사용하면 메모리와 관련해서 흔히 발생하는 문제를 방지할 수 있다.

스마트 포인터로 지정한 객체가 스코프를 벗어나면

(예를 들어 호출한 함수의 실행이 끝나면) 메모리가 자동으로 해제된다.

 

C++에서 가장 중요한 스마트 포인터 타입은 다음 두 가지다.

둘 다 <memory>헤더 파일에 정의되어 있으며 std 네임스페이스에 속해 있다.

 

  • std::unique_ptr
  • std::shared_ptr

 

unique_ptr

 

unique_ptr는 포인터로 가리키는 대상이 스코프를 벗어나거나 삭제될 때

할당된 메모리나 리소스도 자동으로 삭제된다는 점을 제외하면 일반 포인터와 같다.

 

그러나 unique_ptr가 가리키는 객체를 일반 포인터로는 가리킬 수 없다.

unique_ptr는 return문이 실행되거나 익셉션(exception)이 발생하더라도

항상 할당된 메모리나 리소스를 해제할 수 있다는 장점이 있다.

 

그래서 함수에 return문을 여러 개 작성하더라도 각각에 대해 리소스를 해제하는

코드를 작성할 필요가 없기 때문에 함수를 간결하게 작성할 수 있다.

 

unique_ptr를 생성할 때는 반드시 std::make_unique<>()를 사용해야 한다.

auto anEmployee = make_unique<Employee>();

 

delete가 자동으로 호출되기 때문에 delete를 호출하는 문장을 따로 적어둘 필요가 없다는 것을

다시 한번 상기시키자.

 

unique_ptr은 제네릭 스마트 포인터라서 어떠한 종류의 메모리도 가리킬 수 있다.

그래서 템플릿으로 만든 것이다.

템플릿은 매개변수를 꺽쇠괄호 <>로 묶어서 지정한다.

 

shared_ptr

 

shared_ptr를 사용하면 데이터를 공유할 수 있다.

shared_ptr에 대한 대입 연산이 발생할 때 마다 레퍼런스 카운트(참조 횟수)가 하나씩 증가한다.

shared_ptr가 가리키는 데이터를 레퍼런스 카운트만큼 소유하고 있다는 것을 표현한다.

shared_ptr가 스코프를 벗어나면 레퍼런스 카운트가 감소한다.

 

그러다 레퍼런스 카운트가 0이 되면 

그 데이터를 아무도 가지고 있지 않기 때문에 포인터로 가리키던 객체를 해제한다.

 

++

기존에 사용하던 원시 포인터는

소유권과 관련이 없는 경우에만 사용한다.

그 외에는 unique_ptr를 기본으로 사용하고,

소유권을 공유할 필요가 있다면 shared_ptr를 사용한다.

++

 

 

const의 다양한 용도
C++에서 const 키워드는 다양하게 사용된다.
각각의 용도는 서로 관련돼 있지만 미묘한 차이가 있다.

 

const는 각각의 용도는 서로 관련되어 있지만 미묘한 차이가 있기 때문에

면접에서 물어보기 딱 좋다.

const 키워드의 활용 예는 해당 챕터에서 두 가지 사례만 소개한다.

(해당 서적 1파트 1챕터에서는 const 용법 2가지를 소개하고 있다.)

 

1. const 상수

 

const 키워드 이름에서 알 수 있듯이 상수와 관련있다.

프로그램을 실행하는 동안 변경하면 안 되는 값에 이름을 붙일 때 전처리 구문인

#define 방법은 C 스타일이다.

C++에서는 #define 대신에 const 로 정의하는 것이 바람직하다.

const로 상수를 정의하는 방법은 변수를 정의할 때와 거의 같고,

값이 변경되지 않도록 보장하는 작업은 컴파일러가 처리한다는 점만 다르다.

 

 

2. const 매개변수

C++에서는 non-const(const가 아닌) 변수를 const로 캐스팅할 수 있다.

이렇게 하면 다른 코드에서 변수를 변경하지 않도록 어느 정도 보호할 수 있다.

 

동료가 작성한 함수를 호출할 때 여러분이 전달한 매개변수가

변경되지 않도록 보장하고 싶다면 동료에게 const 매개변수를 받도록 함수를 작성해달라고 하면 된다.

이렇게 작성한 함수 안에서 매개변수의 값을 변경하면 컴파일 오류가 발생한다.

 

#include <iostream>
#include <string>
#include <vector>

using namespace std;

void mystetyFunction(const string* someString) {
	*someString = "Test";
}


int main(void)
{
	
	string myString = "The String";

	// 함수 내부에서 값 변경 불가능
	mystetyFunction(&myString);

	return 0;

}

 

 

 

레퍼런스
C++에서 제공하는 레퍼런스를 사용하면 기존 변수에 새 이름을 저장할 수 있다.

 

 

변수의 타입 뒤에 &를 붙이면 그 변수는 레퍼런스가 된다.

일반 변수와 같지만 내부적으로는 원본 변수에 대한 포인터로 취급한다.

(= 어셈블리어 수준에서는 포인터와 같다.

안전하게 포인터를 쓰기위한 방법)

 

1. 레퍼런스 전달 방식

 

일반적으로 함수에 전달한 변수는 값 전달 방식(pass by value)로 처리한다.

예를 들어 함수의 매개변수에 정수를 전달하면, 함수 안에는 그 정수의 복제본이 전달된다.

 

따라서 함수 안에서 원본 변수의 값을 변경할 수 없다.

C에서는 스택 변수에 대한 포인터를 자주 사용했는데,

이런 방식을 사용하면 다른 스택 프레임에 있는 원본 변수를 수정할 수 있다.

이러한 포인터를 역참조하면 그 포인터가 현재 스택 프레임을 가리키지 않더라도

함수 안에서 그 변수가 가리키는 메모리의 값을 수정할 수 있다.

 

그런데 이 방식은 포인터 연산이 많아져서 간단하게 작업하더라도 코드가 복잡해진다.

 

C++에서는 값 전달 방식보다 뛰어난 레퍼런스(참조) 전달 방식을 제공한다.

이 방식을 사용하면 매개변수가 포인터값이 아닌 레퍼런스로 전달된다.

이러한 방식은 포인터를 쓰는 것과 같이 원본 변수의 값도 변경된다.

 

뒤에서 소개하겠지만

(면접 시 말하면 좋을 것 같다.)

복제하는 데 부담스러울 정도로 큰 구조체나 클래스를 리턴하는 함수를 구현할 때는

구조체나 클래스를 non-const 레퍼런스로 받아서 원하는 작업을 수행한 뒤

그 결과를 직접 리턴하지 않고 내부에서 곧바로 수정하는 방식을 많이 사용한다.

 

함수에서 구조체나 클래스를 복제함으로써

발생하는 성능 저하를 최소화하기 위해서 오래전부터 사용하던 방식이다.

 

하지만 C++11부터 추가된 의미 이동론 (무브 시멘틱스 move sementics) 덕분에

복제하지 않고 구조체나 클래스를 직접 리턴할 수 있다.

 

 

2. const 레퍼런스 전달 방식

 

 

원래 레퍼런스 매개변수를 사용하면

변수의 값을 다른 문맥에서 수정할 수 있는데,

const로 지정해버리면 그렇게 할 수 없는 것처럼 보이기 때문이다.

 

const 레퍼런스의 가장 큰 장점은 성능이다.

함수에 매개변수를 값으로 전달하면 그 값 전체가 복제된다.

하지만 레퍼런스로 전달하면 원본에 대한 포인터만 전달되기 때문에

원본 전체를 복제할 필요가 없다.

 

또한 const 레퍼런스로 전달하면

복제되지도 않고 원본 변수가 변경되지 않는 장점을 모두 취할 수 있다.

 

const 레퍼런스는 특히 객체를 다룰 때 유용하다.

객체는 대체로 커서 복제하는 동안 의도하지 않은 효과가 발생할 수 있기 때문이다.

 

 

익셉션

 

C++의 유연성이 굉장히 뛰어난 반면 안전성은 그리 좋지 않다.

메모리 공간을 무작위로 하거나 0으로 나누는 연산을 수행하더라도 컴파일러는 가만히 내버려둔다.

(컴퓨터는 무한을 다룰 수 없음에도 불구하고 말이다.)

 

이처럼 C++의 안전성을 좀 더 높이기 위해 제공하는 기능 중 하나가 익셉션(예외 처리)이다.

 

예외처리란 예상하지 못한 상황을 표현하는 클래스/객체이다.

 

예를 들어 웹 페이지를 조회하는 함수에는 다양한 문제가 발생할 수 있다.

 

그 페이지를 제공하는 서버가 다운될 수도 있고,

빈 페이지만 전달될 수도 있고,

인터넷 연결이 끊어질 수도 있다.

 

이렇게 예상하지 못한 상황에 대처하는 한 가지 방법은

그 함수에서 nullptr나 에러 코드와 같은 특수한 값을 리턴하는 것이다.

익셉션을 활용하면 문제가 발생했을 때 좀 더 융통성 있게 대처할 수 있다.

 

익셉션과 관련하여 몇 가지 새로운 용어가 등장한다.

 

1. throw(발생시킨다, 던진다)

코드에서 특정한 조건을 만족해서 익섹셥을 발생시키는 것을 익셉션을 던진다고 표현하고,

throw 구문으로 작성한다.

 

2. catch(받는다, 처리한다)

이렇게 발생된 익셉션을 대해 적절한 동작을 수행하는 것을 익섹션을 잡는다고 표현하고,

catch 구문을 작성한다.

 

해당 코드 안에서는

이 함수가 전달된 분모의 인수가 0이면 익셉션을 발생시킨다.

여기서는 std::invalid_argument란 익셉션을 사용했는데,

이렇게 하려면 <stdexcept> 헤더 파일을 불러와야 한다.

 

double divideNumbers(double numerator, double denominator)
{
	if (denominator == 0)
	{
		throw invalid_argument("Denominator cannot be 0.");
	}
	return numerator / denominator;
}

 

 

throw 문장이 실행되면 함수에서 값을 리턴하지 않고 실행을 즉시 중단한다.

이처럼 익셉션이 발생하는 함수를 호출할 때는 다음 코드처럼 try/catch 블록으로 감싼다.

그러면 함수에서 익섭션이 발생할 때 적절히 대처할 수 있다.

#include <iostream>
 
using namespace std;
 
int main()
{
	int a, b;
 
	cout << "두 개의 정수를 입력하세요: ";
	cin >> a >> b;
 
	try {
		if (b == 0) throw b;
		cout << a << "를 " << b << "로 나눈 몫은 " << a/b << "입니다." << endl;
	} catch (int exception) {
		cout << "예외 발생, 나누는 수는 " << b << "가 될 수 없습니다." << endl;
	}
	return 0;
}

 

 

출저&nbsp;https://blog.hexabrain.net/179

 

 

 

 

throw 문장이 실행되면 함수에서 값을 리턴하지 않고 즉시 중단한다.

이처럼 익셉션이 발생하는 함수를 호출할 때는 다음 코드처럼 try/catch 블록으로 감싼다.

그러면 함수에서 익셉션이 발생할 때 적절히 대처할 수 있다.

 

	try {
		cout << divideNumbers(2.5, 0.5) << endl;
		cout << divideNumbers(2.3, 0) << endl;
		cout << divideNumbers(4.5, 2.5) << endl;
	}
	catch (const invalid_argument& exception) {
		cout << "Exception caught: " << exception.what() << endl;
	}

 

 

 

divideNumbers()를 처음 호출할 때는 정상적으로 실행돼 결과가 화면에 출력된다.

두 번째 호출할 때는 익셉션이 발생한다.

아무런 값도 리턴되지 않기 때문에 익셉션을 잡았다는 에러 메시지만 화면에 출력한다.

세 번째로 호출하는 문장은 실행되지 않는다.

 

이미 두 번째 호출에서 익셉션이 발생해서

프로그램의 실행 흐름이 곧바로 catch 블록을 건너뛰었기 때문이다.

앞에 나온 코드를 실행한 결과는 다음과 같다.

 

 

 

C++에서 익셉션을 제대로 처리하기 힘들 수도 있다.

예외처리를 던질 시점에 스택 변수에서 어떤 일이 일어나는지 파악해야 한다.

그리고 발생한 익셉션 중에 꼭 처리해야 할 것만 같아서 적절히 대처해야 한다.

 

앞에서는 C++에서 기본으로 제공하는 std::invalid_argument 타입을 사용했는데,

기왕이면 발생할 수 있는 상황에 딱 맞게 익셉션 타입을 직접 정의해서 사용하는 것이 좋다.

 

마지막으로 C++ 컴파일러는 발생 가능한 모든 익셉션을 꼭 잡도록 강제하지 않는다.

익셉션을 처리하는 코드를 따로 작성하지 않으면 프로그램 자체에서 처리하는데,

그러면 프로그램이 그냥 종료된다.

(익셉션과 관련된 미묘한 이슈는 14장에서 계속된다.)

 

 

타입 추론

 

타입 추론이란 표현식의 타입을 컴파일러가 스스로 알아내는 기능이다.

타입 추론과 관련된 키워드로 auto와 decltype이 있다.

 

auto 키워드

 

auto 키워드는 다음과 같이 다양한 상황에서 사용한다.

 

  • 앞에서 설명한 것처럼 함수의 리턴 타입을 추론한다.
  • 앞에서 설명한 것처럼 구조적 바인딩에 사용한다.
  • 이 절 뒤에서 설명하겠지만,
    표현식의 타입을 추론하는 데도 사용한다.
  • 비타입(non-type, 타입이 아닌) 템플릿 매개변수의 타입을 추론하는 데 사용한다.
  • decltype(auto)에서 사용한다.
  • 함수에 대한 또 다른 문법으로 사용한다.
  • 제네릭 람다 표현식에 사용한다.

 

즉 변수를 선언할 때 타입 자리에 auto 키워드를 지정하면

그 변수의 타입은 컴파일 시가넹 자동으로 추론해서 결정된다.

 

auto로 쓸 때의 이점은, 복잡한 타입에 적용할 때 편리하다.

auto를 붙이면 세부 작업은 컴파일러가 처리해주는 것이다.

 

또한 이렇게 하면 나중에 이 함수의 리턴 타입을 변경하더라도

코드에서 그 함수가 나온 모든 지점을 일일이 찾아서 고칠 필요 없이 간단히 수정할 수 있다.

 

auto를 지정하면 레퍼런스와 const 한정자가 사라지기 때문에

 

auto f1 = foo();

 

auto를 지정하면 레퍼런스와 const 한정자가 사라지기 때문에 f1은 string 타입이 된다.

따라서 값이 복제돼버린다.

const 레퍼런스 타입으로 지정하려면

다음과 같이 auto 키워드 앞뒤에 레퍼런스 타입과 const 키워드를 붙인다.

 

const auto & f2 = foo();

 

++

auto를 지정하면 레퍼런스와 const 지정자가 사라져서 값이 복제된다는 점에 주의한다.

복제 방식으로 전달되지 않게 하려면 auto& 나 const auto&로 지정한다.

++

 

 

2. decltype 키워드

 

decltype 키워드는 인수로 지정한 표현식의 타입을 알아낸다.

 

int x = 123;
decltype(x) y = 456;

 

이렇게 작성하면 컴파일러는 y의 타입이 x의 타입인 int라고 추론한다.

 

decltype은 레퍼런스나 const 지정자를 삭제하지 않는다는 점에서 auto와는 다르다.

#include <iostream>

struct A {
  double d;
};

int main() {
  int a = 3;
  decltype(a) b = 2;  // int

  int& r_a = a;
  decltype(r_a) r_b = b;  // int&

  int&& x = 3;
  decltype(x) y = 2;  // int&&

  A* aa;
  decltype(aa->d) dd = 0.1;  // double
}

 

 

위 코드의 경우 decltype이 각각 int, int&, int&& 로 치환되서 컴파일 되게 된다.

위와같이 decltype에 전달된 식이

괄호로 둘러쌓이지 않은 식별자 표현식(id-expression) 이라면 해당 식의 타입을 얻을 수 있다.

 

식별자 표현식이란 변수의 이름, 함수의 이름, enum의 이름, 클래스 멤버 변수 등을 뜻한다.

쉽게 생각하면 어떤 연산을 하지 않고 단순히 객체 하나만 가리키는 식이라고 보면 된다.

 

그렇다면 만약 decltype에 식별자 표현식이 아닌 식을 전달하면 어떻게 될까?

그렇다면 해당 식의 값 종류에 따라 달라진다.

 

  • 만약 식의 종류가 lvalue라면 decltype은 T&& 가 된다.
  • 만약 식의 종류가 xvalue라면 decltype은 T& 가 된다.
  • 만약 식의 종류가 pvalue라면 decltype은 T 가 된다.

 

Value Category

 

사람의 경우 이름과 나이라는 정보가 항상 따라다니듯이,

모든 C++식 (expression)에는 두 가지 정보가 항상 따라다닌다.

바로 식의 타입과 값 카테고리(value category)이다.

 

C++에서 어떠한 식의 값 카테고리를 따질 때 크게 두 가지 질문을 던질 수 있다.

 

 

  • 정체를 알 수 있는가?
    정체를 알 수 있다는 말은 곧
    해당 식이 어떤 다른 식과 같은 것인지 아닌지를 구분할 수 있다는 말이다.
    일반적인 변수라면 주소값을 취해서 구분할 수 있겠고,
    함수의 경우라면 그냥 이름만 확인해보면 될 것이다.

  • 이동시킬 수 있는가?
    해당 식을 다른 곳으로 안전하게 이동할 수 있는지의 여부를 묻는다.
    즉 해당 식을 받는 이동 생성자, 이동 대입 연산자 등을 사용할 수 있어야만 한다.

 

  이동 시킬 수 있다 이동 시킬 수 없다
정체를 알 수 있다 xvalue lvalue
정체를 알 수 없다 prvalue 쓸모 없음!

 

 

덧붙여서 정체를 알 수 있는 모든 식들을 glvalue 라고 하며,

이동 시킬 수 있는 모든 식들을 rvalue라고 한다.

그리고 C++ 에서 실체도 없으면서 이동도 시킬 수 없는 애들은 어차피

언어 상 아무런 의미를 갖지 않기 때문에 따로 부르는 명칭은 없다.

 

 

각 값 카테고리를 단순하게 번역해보자면 각각

lvalue는 좌측값,

prvalue(pure rvalue)는 순수 우측값,

xvalue(eXpiring value) 소멸하는 값이다.

glvalue는 일반화된 좌측값, rvalue는 우측값이라 부를 수 있다.

출저 : https://modoocode.com/294

 

위 그림을 보면 어떤 식으로 구분될 수 있는지 이해가 더 잘 될 것이다.

 

해당 이론은 구조적 바인딩과 같이 추가로 C++에 따로 정리하여 포스팅 하겠다.

 

 

클래스의 정의

 

 

클래스란 객체의 특성을 정의한 것이다.

C++에서 클래스를 선언하는 코드는 주로 헤더 파일에 작성하고,

구체적으로 구현하는 코드는 소스 파일에 작성한다.

 

클래스를 정의할 때는 먼저 클래스 이름부터 적고,

중괄호 안에 이 클래스의 데이터 멤버(deta memder(속성))과 메서드(method(동작))을 선언한다.

 

각각의 데이터 멤버와 메서드마다

public, protected, private등으로 접근 수준을 지정한다.

이떄 레이블 (public, private 등)을 나열하는 순서는 따지지 않으며 중복 가능하다.

 

public으로 지정한 멤버는 클래스 밖에서 접근할 수 있다.

private로 지정된 것은 클래스 외부에서 접근할 수 없다.

protected는 상속받은 클래스들만 접근 가능하다.

 

 

++

const-정확성 원칙(const-correctness principle)에 따르면

객체의 데이터 멤버 값을 변경하지 않는 멤버 함수는 항상 const로 지정하는 것이 좋다.

이렇게 지정한 멤버 함수를 인스펙터(inspector), 접근자 또는 게더(getter)라 부르며,

non-const 멤버 함수를 '뮤테이터'(mutator, 변경자)라고 부른다.

++

 

#pragma once

#include <string>

class AirlineTicket
{
public:
	AirlineTicket();
	~AirlineTicket();

	// C++에서 함수 뒤에 const 한정자를 붙이면
	// 해당 함수가 호출된 (클래스 내부)객체를 변경하지 않음을 나타낸다.
	double calculatePrinceInDollars() const;

	const std::string& getPassengerName() const;
	void setPassengerName(const std::string& name);

	int getNumberOfMiles() const;
	void SetNumberOfMiles(int miles);

	bool hasEliteSuperRewardsStatus() const;

	void setHasEliteSuperRewardsStatus(bool status);
};

 

 

클래스와 이름이 같고 리턴 타입이 없는 메서드를 생성자(constructor)라 부른다.

이 메서드는 해당 크랠스의 객체를 생성할 때 자동으로 호출된다.

 

생성자로 데이터 멤버를 초기화하는 방법은 두 가지다.

권장하는 방법은 생성자 이니셜라이저를 사용하는 것으로,

생성자 이름 뒤에 콜론(:)을 붙여서 표현한다.

 

AirlineTicket::AirlineTicket()
    : mPassengerName("Unknown Passenger")
    , mNumberOfMiles(0)
    , mHasEliteSuperRewardsStatus(false)
{
}

 

두 번째 방법은 다음과 같이 생성자의 본문에서 초기화 하는 것이다.

 

AirlineTicket::AirlineTicket()
{
    mPassengerName = "Unknown Passenger";
    mNumberOfMiles = 0;
    mHasEliteSuperRewardsStatus = false;
}

 

생성자에서 다른 일은 하지 않고 데이터 멤버를 초기화하는 일만 한다면

굳이 생성자를 따로 정의할 필요가 없다.

클래스를 정의하는 코드 안에서 곧바로 데이터 멤버를 초기화해도 되기 때문이다.

 

	std::string mPassengerName = "Unknown Passenger";
	int mNumberOfMiles = 0;
	bool mHasEliteSuperRewardsStatus = false;

 

 

클래스에서 파일을 열거나 메모리를 할당하는 것처럼

다른 타입에 대한 초기화 작업은 생성자에서 처리해야 한다.

 

특별히 소멸자가 할 일이 없을 때는 굳이 소멸자를 작성할 필요는 없다.

하지만 파일을 닫거나 메모리를 해제하는 등의 정리 작업이 필요하다면 소멸자를 정의한다.

 

클래스 사용하기

 

다음 코드는 미리 만들어둔 AirlineTicket의 클래스를 사용하는 예를 보여준다.

AirlineTicket 객체를 하나는 스택 기반으로, 하나는 힙 기반으로 생성한다.

 

int main(void)
{
	// 스택 기반 AirlineTicket
	AirlineTicket myTicket;
	myTicket.setPassengerName("Sherman T. Socketwrench");
	myTicket.SetNumberOfMiles(700);
	double cost = myTicket.calculatePrinceInDollars();
	cout << "This ticket will cost $" << cost << endl;

	// 스마트 포인터를 사용한 힙 기반 AirlineTicket
	auto myTicket2 = make_unique<AirlineTicket>();
	myTicket2->setPassengerName("Laudimore M. Hallidue");
	myTicket2->SetNumberOfMiles(2000);
	myTicket2->setHasEliteSuperRewardsStatus(true);
	double cost2 = myTicket2->calculatePrinceInDollars();
	cout << "This other ticket will cost $" << cost2 << endl;

	// 스마트 포인터를 사용하지 않은 힙 기반 AirlineTicket(바람직하지 않은 방식)
	AirlineTicket* myTicket3 = new AirlineTicket();
	// ... 티켓 3을 사용한다
	delete myTicket3; // 힙 객체를 삭제한다.

	return 0;

}

 

해당 예제를 통해 객체를 생성하고 클래스를 사용하는 문법을 볼 수 있다.

물론 이 외에도 다양한 방법과 기능이 있다.

 

 

1.4 유니폼 초기화

 

C++11 이전에는 타입의 초기화 방식이 일정하지 않았다.

예를 들어 다음과 같이 원을 정의할 때는 한 번은 구조체로 한번은 클래스로 작성한 경우를 살펴보자.

 

struct CircleStruct
{
	int x, y;
	double radius;
};

class CircleClass
{
public:
	CircleClass(int x, int y, double radius)
		: mX(x), mY(y), mRadius(radius) {}
private:
	int mX, mY;
	double mRadius;
};

 

 

C++ 11 이전에는

CircleStruct 타입 변수와 CircleClass 타입 변수를 초기화 하는 방법이 서로 달랐다.

 

	CircleStruct myCircle1 = { 10,10,2.5f };
	CircleClass myCircle2(10, 10, 2.5);

 

구조체에 대해서는 { . . . } 문법을 적용한 반면,

클래스에 대해서는 함수 표기법인 ( . . . )로 생성자를 호출했다.

 

그런데 C++11 부터는

타입을 초기화할 때 { . . . } 문법을 사용하는 유니폼 초기화(uniform intialization)(균일 초기화, 중괄호 초기화)

를 따르도록 통일됐다.

 

	CircleStruct myCircle3 = { 10,10,2.5f };
	CircleClass myCircle4 = { 10, 10, 2.5 };

 

이러한 유니폼 이니셜라이저는 구조체나 클래스뿐만 아니라

C++에 있는 모든 대상을 초기화 하는 데 적용된다.

 

예를 들어 다음 코드는 네 변수를 모두 3이란 값으로 초기화한다.

 

	int a = 3;
	int b(3);
	int c = { 3 };	// 유니폼 초기화
	int d{ 3 };		// 유니폼 초기화

 

유니폼 초기화는 변수를 영 초기화(제로 초기화)할 때도 적용할 수 있다.

다음과 같이 중괄호로 빈 집합 표시만 해주면 된다.

int e{};

 

유니폼 초기화를 사용하면 축소 변환(narrowing)(좁히기)을 방지할 수 있다.

C++에서는 암묵적으로 축소 변환될 떄가 있는데,

예를 들면 다음과 같다.

 

void func(int i ) { /* . . . */ }

int main(void)
{
	int x = 3.14;
	func(3.14);

	return 0;

}

 

 

x에 값을 대입할 때와 func()를 호출할 때 전달한 3.14는 3으로 줄어든다.

컴파일러에 따라 이렇게 축소할 때마다 경고 메시지가 발생할 수도 있다.

 

	int x = { 3.14 };	// 축소로 인한 에러
	func({ 3.14 });		// 축소로 인한 에러

 

유니폼 초기화는 동적으로 할당되는 배열을 초기화할 때도 적용할 수 있다.

예를 들면 다음과 같다.

 

int* pArray = new int[4]{0,1,2,3};

 

또한 클래스 멤버인 배열을 생성자 이니셜라이저로 초기화 할 때 역시 사용 가능하다.

 

class MyClass
{
public:
	MyClass() : mArray{ 0,1,2,3 } {};
private:
	int mArray[4];
};

 

 

직접 리스트 초기화와 복제 리스트 초기화

 

 

이니셜라이저는 다음 두 가지가 있으며,

이니셜라이저 리스트를 중괄호로 묶어서 표현한다.

 

  • 복제 리스트 초기화 (copy list initialization) : T obj = {arg1, arg2, ... };
  • 직접 리스트 초기화 (direct list initialization) : T obj = {arg1, arg2, ... };

 

C++17부터는 auto 타입 추론 기능과 관련하여 복제 리스트 초기화와

직접 리스트 초기화가 크게 달라졌다.

 

C++17 이전 (C++11/14)에는 복제 리스트 초기화와 직접 리스트 모두 

initializer_list<>로 처리했다.

 

 

C++17부터 auto는 직접 리스트 초기화에 대해 값 하나만 추론한다.

따라서 이 코드를 실행하면 에러가 발생한다.

 

 

복제 리스트 초기화에서 중괄호 안에 나오는 원소는 반드시 타입이 모두 같아야한다.

 

 

 

 

해당 게시글은 전문가를 위한 C++ 4판을 기반으로 작성되었으며,

추가로 곁가지 지식을 검색 후 정리한 게시글입니다.

전문가를 위한 C++ 4판은

기초적인 C++에 대한 책을 2~3권 혹은 1권을 2~3회독 하신 분께 추천드립니다.

전문가를 위한 C++4 판은 C++17까지의 정보를 다루며,

필자의 추천은 4판을 정독한 후 5판을 읽는 것을 추천드립니다.

5판은 C++ 20까지의 정보를 다루는 것으로 알고있습니다.

현재 시점 기준으로 4판은 온라인 서점에서 구매할 수 있으므로 구매하기를 추천드립니다.

저작권 문제로 인하여 모든 정리글을 공개하지 않는 점 양해 부탁드립니다.