서적 '이펙티브 C++'을 공부하면서 개인적인 복습을 목적으로 기록하는 포스팅입니다.
책 전문은 포함되어 있지 않으며, 책을 학습하다가 궁금했던 내용 위주로 보충 공부한 것을 주로 다룹니다.
1. C++을 언어들의 연합체로 바라보는 안목은 필수
[요약]
멀티패러다임 언어로서의 C++ 개념
- 초창기의 C++은 C언어를 기반으로 객체 지향 기능 몇가지가 결합된 형태의, '클래스를 쓰는 C'였습니다.
- 그러나 이후 C++은 현재까지도 계속해서 새로운 문법이 추가되며 확장 및 발전되고 있습니다.
- 그렇기 때문에 오늘날의 C++은 다중패러다임(=멀티패러다임) 프로그래밍 언어라고 불립니다.
- 즉, C++은 절차적 프로그래밍을 기본으로 객체 지향, 함수식, 일반화 프로그래밍을 포함한 메타프로그래밍 개념까지 지원합니다.
- 따라서 C++은 단일 언어라기 보다는, 상관 관계가 있는 여러 하위 언어들을 제공하는 일종의 연합체라고도 할 수 있습니다.
- 여기서 하위 언어에 해당하는 것들은 총 4가지로 C, 객체 지향 개념의 C++, 템플릿 C++, STL이 있습니다.
- [이 관점이 중요한 이유: C++의 어떤 부분을 사용하는지, 그 상황에 따라 변하는 '최적의 법칙']
C++를 연합체로 바라봐야 하는 가장 큰 이유는, 각각의 하위 언어마다 효율적인 코드를 작성하는 규칙(rule of thumb)이 달라지기 때문입니다. 예를 들어, 함수의 '매개변수를 전달하는 가장 좋은 방법'이라는 단순한 주제조차도 어떤 하위 언어의 관점에서 보느냐에 따라 답이 달라집니다.
- C 언어 관점에서
: struct 같은 사용자 정의 타입이라도 크기가 작다면 값으로 전달(pass-by-value)하는 것이 효율적일 때가 많습니다. 포인터를 쓰는 것보다 단순하고 캐시 친화적일 수 있죠.
- 객체 지향 C++ 관점에서
: 상속과 다형성이 적용된 복잡한 클래스 객체는 생성/소멸 비용이 크기 때문에, 상수 참조자로 전달(pass-by-reference-to-const)하는 것이 거의 모든 경우에 일반적인 규칙이 됩니다. 불필요한 객체의 복사를 막기 위함이죠.
- 템플릿 C++ 관점에서
: 상황이 또 달라집니다. 다양한 타입이 들어올 수 있는 템플릿 코드에서는, 컴파일러의 최적화 방식과 맞물려 오히려 값으로 전달(pass-by-value)하는 것이 더 효율적인 선택지가 될 수 있습니다. (이는 나중에 배울 '이동 의미론'과도 깊은 관련이 있습니다.)
- STL 관점에서
: 이터레이터(iterator)와 함수 객체(function object)를 값으로 전달하는 것이 STL의 일반적인 관례입니다. 포인터처럼 동작하지만 포인터는 아닌, STL만의 독자적인 규칙을 따르는 것이 중요합니다.
2. #define을 쓰려거든 const, enum, inline을 떠올리자
[요약]
- #define(매크로)은 선행 처리자(Preprocessor)에 해당됩니다.
- 그러나 선행 처리자는 컴파일러가 소스 코드를 본격적으로 컴파일하기 전에 단순 치환으로 동작합니다.
- 즉, 컴파일러가 관리하는 심볼 테이블에 등록되지 않아 타입 검사와 최적화, 변수명 충돌 방지 등을 제대로 처리할 수 없습니다.
- 이러한 문제를 해결하기 위해서 컴파일러가 이해할 수 있는 방식을 사용하는 것이 좋습니다.
- 목적에 따른 #define의 대안은 아래와 같습니다.
목적 | #define 대안 | 이유 |
상수 정의 | const / constexpr | 타입 정보 + 컴파일 타임 검사 가능 |
열거 값 정의 | enum / enum class | 네임스페이스 분리 및 정수형 연산 안전 |
간단한 매크로 함수 | inline 함수 | 타입 안전 + 디버깅 쉬움 + 매개변수 평가 문제 없음 |
3. 낌새만 보이면 const를 들이대 보자!
[요약]
- const 키워드는 어떤 값(객체의 내용)이 불변인 상수라는 것을 소스 코드에 명시하고 컴파일러가 이 제약을 지키게끔 강제합니다.
- 따라서 가능한 다양한 경우에서 const를 사용하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다.
- mutable 키워드를 따로 사용하지 않는 이상 const 키워드를 사용한 값의 속성은 절대 불변으로 유지됩니다.
(이와 관련된 비트수준 상수성-물리적 상수성-, 그리고 논리적 상수성에 대한 추가 설명은 아래에 있습니다.)
- 컴파일러 쪽에서 보면 비트 상수성을 지켜야 하지만, 프로그래머의 경우 mutable 키워드를 사용해 보다 유연하게 개념적인(논리적) 상수성을 사용해 프로그래밍 할 수 있습니다.
- 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 동일하게 작동할 경우 코드 중복을 피하기 위해 비상수 버전이 상수 버전을 호출하도록 구조를 설계하는 것이 좋습니다.
- C++에서 const는 다음과 같은 상황에서 사용할 수 있습니다.
1. 변수 선언 시 const 사용
const int a = 10;
위의 코드에서 변수 a는 수정할 수 없는 불변값, 즉 상수 값이 됩니다.
이렇게 상수화가 된 데이터 값은 스택 영역, 힙 영역, 정적 영역 중 어디서 선언하든 동일한 값을 유지하며 임의 변경되지 않습니다.
2. 포인터에서의 const 사용
- 포인터에서 const 키워드는 그 키워드가 무엇을 수식하는지에 따라서 의미가 달라지며 총 4가지 조합이 존재합니다.
선언 | 의미 |
const int* ptr; | int*의 const화 : 주소는 변경이 가능하지만, int* 타입으로 저장된 내부 데이터 값은 변경 불가 (*ptr = 5; 불가) |
int* const ptr; | 포인터 자체의 const화 : 값의 변경은 가능하지만, 포인터가 가리키는 주소는 변경 불가 (ptr = &b; 불가) |
const int* const ptr; | 포인터가 가리키는 주소와 내부에 저장된 데이터 값 모두 고정되어 변경 불가 |
int* ptr; | const 키워드를 사용하지 않았으므로 내부 데이터 값과 포인터 주소 모두 변경 가능 |
3. 함수 인자에서의 const 사용
void print(const std::string& s);
함수 매개변수에서 const 사용시 함수 내부에서 s라는 값을 절대 변경하지 않겠다는 뜻이 됩니다.
이러한 함수 인자에서의 const 사용은 주로 참조(reference) 타입과 함께 사용하여 복사 방지와 안정성을 동시에 확보합니다.
4. 함수 리턴값에서의 const 사용
const std::string getName();
함수 리턴값에 const 키워드를 사용하면 리턴된 값을 변경하지 못하게 방지할 수 있습니다.
하지만 만약 *리턴값이 값 타입이라면 const를 사용하는 것이 의미가 없으므로, 리턴값에 대한 const를 보장하고 싶다면 리턴 타입을 참조 타입으로 지정 해야 합니다.
*리턴값이 값 타입인 경우 의미가 없다는 말은 대체 무슨 의미인가?
예를 들어서 아래와 같이 getName() 함수가 존재한다고 가정해봅시다.
const std::string getName()
{
return "rise";
}
이때의 리턴값 "rise"는 일반 string 값 타입입니다.
함수가 문자열 객체를 리턴하지만 만약 새로운 string 객체를 만들고 이 값을 대입한다면
리턴 값을 복사해서 temp에 대입되기 때문입니다.
즉, 복사한 시점에서 const는 사라지고 temp는 수정 가능한 일반 std::string이 되므로
아래와 같이 리턴된 값을 임의로 변경할 수 있게 되므로 const를 붙여도 원본이 보호되는 효과는 전혀 없게 됩니다.
std::string temp = getName(); // 복사됨
temp = "risehyun"; // 가능함
의도한 대로 리턴값의 원본을 보호하려면 아래와 같이 참조 타입을 사용해야 합니다.
const std::string& getName()
{
return name;
}
여기서 getName() 함수는 name이라는 내부 멤버 변수를 참조(&)로 반환하게 됩니다.
그리고 해당 참조가 const 키워드를 사용해 상수화 되었기 때문에, 결론적으로 호출자는 그 값을 임의로 변경할 수 없게 됩니다.
const std::string& ref = getName();
ref = "risehyun"; // X (컴파일 에러)
추가로, 만약에 const 없이 참조만 리턴하게 되면 어떻게 될까요?
std::string& getName(); // 비-const 참조 리턴
const 키워드 없이 참조만 달랑 리턴하게 되므로 이제 이 함수를 사용해버리면 내부 name을 자유자재로 바꿀 수 있는 대참사가 벌어집니다.
getName() = "risehyun"; // 아예 내부 name이 바뀌어 버림.
정리하자면 "참조형 반환에 const를 붙여 내부 객체를 외부에서 변경하지 못하게 보호하는 효과를 얻을 수 있다.
대신, 참조형 사용시 반드시 const를 붙여서 실수하지 않도록 주의하자." 는 것이 함수 리턴에서의 const 사용에 대한 요점입니다.
5. 멤버 함수에서의 const 사용
class Person
{
public:
std::string getName() const;
};
멤버 함수에서 const를 사용하면 해당 멤버 변수 값을 절대 바꾸지 않겠다는 의미가 됩니다.
내부적으로 이 선언은 아래와 같이 작동합니다.
std::string getName(const Person* this);
멤버 함수의 숨겨진 this 포인터가 const Person*이 되어버리는 것입니다.
그렇기 때문에 내부에서 this->name = "...." 와 같은 임의의 수정이 불가능해집니다.
따라서 아래의 예제처럼 const로 생성된 const Person 객체에서는 해당 함수 getName()의 호출 자체가 불가능합니다.
class Person {
public:
std::string getName(); // 일반 멤버 함수
std::string getConstName() const; // const 멤버 함수
};
const Person p;
p.getName(); // X, 호출 불가 (컴파일 에러)
p.getConstName(); // O, 호출 가능
6. 클래스 멤버 변수에서의 const 사용
class Myclass
{
const int id;
};
클래스 멤버 변수에 const를 사용할 경우, 해당 객체의 수명 유효 시점 동안 해당 멤버 값은 불변이 보장됩니다.
여기서 주의할 점은 객체 생성시 아래의 예제처럼 반드시 값의 초기화가 필요하다는 것입니다. (생성자 초기화 리스트 사용 필수)
#include <iostream>
#include <string>
class Person
{
private:
const std::string name; // const 멤버 변수
public:
// 생성자에서 초기화 리스트로 const 멤버 초기화
Person(const std::string& newName) : name(newName) {
std::cout << "생성됨: " << name << std::endl;
}
void greet() const {
std::cout << "안녕하세요, 저는 " << name << "입니다." << std::endl;
}
};
int main() {
Person p("risehyun");
p.greet(); // 출력: 안녕하세요, 저는 risehyun입니다.
}
7. 정적 멤버 변수에서의 const
class Config
{
static const int MaxSize = 100;
};
정적 함수는 클래스 내부에서 초기화가 가능합니다.
단, C++ 버전에 따라 정수형과 열거형만 가능할 수도 있다는 점에 주의해야 합니다.
표준 | 클래스 내부에서 static const 초기화가 가능한 타입 |
C++98/03 | 정수형, 열거형만 가능, 복합 타입은 불가 |
C++11 | 정수형, 열거형 가능, constexpr 도입으로 리터럴 타입 확장 가능 (단, 여전히 제한적) |
C++17 | 모든 타입 가능 (inline static 도입), 복합 타입도 초기화 가능 |
C++20 이후 | consteval, constinit 등 추가 도구 제공 |
C++ 11 이후 static const 초기화 방법에 대한 방법과 예시는 다음과 같습니다.
상황 | 방법 | 예시 |
C++11 이상 | constexpr로 리터럴 타입 가능 | static constexpr double PI = 3.14; |
C++17 이상 | inline static const std::string name = "다현"; 가능 | 복합 타입도 OK |
C++03 이하 | static const int 등 정수형만 초기화 가능 | static const int x = 10; |
이렇게 정적 멤버 변수에 const를 사용하는 경우는 주로 *컴파일 타임 상수로 사용됩니다.
*컴파일 타임 상수란 무엇인가?
프로그램이 컴파일 되는 순간 값이 정해지며 이후 절대 변하지 않는 상수를 의미합니다.
예를 들어 다음과 같이 *constexpr 키워드를 사용해 컴파일 타임 상수를 선언하면 컴파일 중에 a를 10이라는 값으로 확정합니다.
constexpr int a = 10;
이렇게 확정된 a의 값은 이미 컴파일러가 알고 있기 때문에 추후에 최적화에도 활용할 수 있습니다.
*constexpr 키워드란 무엇인가?
변수나 함수가 컴파일 타임에 반드시 계산 가능해야 함을 보장하는 키워드 입니다.
constexpr int x = 3 + 5; 처럼 상수를 만들 수 있고, constexpr 함수는 상수 표현식에 사용될 수 있습니다.
단, 컴파일 타임에 평가 가능한 문법만 포함해야 합니다.
템플릿 매개변수, 배열 크기, 열거 값 등 컴파일 시간 상수를 요구하는 곳에 유용하게 사용됩니다.
아래의 표는 const와 constexpr 키워드의 차이를 비교한 것입니다.
구분 | const | constexpr |
언제 값이 결정되나요? | 실행 중 (런타임일 수도 있음) | 반드시 컴파일 중 |
사용 제한 | 다소 느슨함 | 엄격함 (컴파일러가 값 계산 가능해야 함) |
함수에 적용 | X 불가 | O 컴파일 타임 함수 선언 가능 |
constexpr 조건 | 리터럴 타입 + 컴파일 타임 계산 가능 |
예제 코드를 보면 작동 원리를 더 명확히 이해할 수 있습니다.
#include <iostream>
#include <cmath>
int getRuntimeValue()
{
return std::rand() % 100;
}
int main()
{
const int a = getRuntimeValue(); // 가능: const지만 실행 중 결정
// constexpr int b = getRuntimeValue(); // 컴파일 에러: 컴파일 타임에 못 정함
constexpr int c = 10 + 20; // 컴파일 타임 계산 가능
std::cout << c << std::endl;
}
위의 예제에서 const int a는 값을 바꾸지 않겠다는 약속은 했지만 그 값이 언제 정해지는가에 대한 제약사항은 없습니다.
따라서 컴파일 타임과 런타임 타임 중 어느쪽에서 값이 결정되더라도 상관이 없습니다.
그러나 constexpr int c의 경우 더욱 엄격한 규칙이 적용되어 값이 불변할 뿐만 아니라 컴파일 타임에 해당 값이 결정되어야 합니다.
(이보다 훨씬 더 엄격한 규칙이 적용되는 C++ 20에 등장한 consteval 키워드도 존재합니다.)
이러한 constexpr 키워드는 실제로 아래와 같은 상황에서 사용됩니다.
- 배열 크기
constexpr int size = 10;
int arr[size]; // 가능
- 템플릿 인자
template<int N>
class Array {};
Array<size> a; // size는 constexpr이어야 가능
- 컴파일 타임 연산 함수
constexpr int square(int x)
{
return x * x;
}
constexpr int val = square(5); // 가능
8. 전역/네임스페이스 상수에서의 const 사용
// File: config.h
const int MaxSize = 100;
이때는 const로 설정된 값들이 기본적으로 내부 연결(internal linkage)로 되어 있어
해당 값이 선언된 파일 내에서만 유효하고, 다른 파일에서는 접근이 불가하게 됩니다.
컴파일러가 이 MaxSize 값을 각각의 번역 단위마다 복사해서 포함시키기 때문입니다.
따라서 다른 파일에서 이 값을 참조할 수 없으며 만약 헤더 파일에 정의한다고 해도
include한 각 cpp 파일마다 값이 복사되므로(값이 바뀌지 않기 때문에 복사해도 상관없다는 가정 하에 작동)
여러 파일에서 동일한 상수를 공유하기 위해 필요한 *ODR 원칙에 위배되는 문제가 발생할 수 있게 됩니다.
따라서 여러 파일에서 해당 값을 공유하고 싶다면 아래와 같이 extern const 키워드를 사용하고 cpp에서 따로 정의가 필요합니다.
// File: config.h
extern const int MaxSize; // 선언만
// File: config.cpp
const int MaxSize = 100; // 정의 (링커가 이걸 찾음)
// File: main.cpp
#include "config.h"
int arr[MaxSize]; // 사용 가능
*ODR 원칙이란 무엇인가?
One definition Rule : 하나의 프로그램에 각 엔티티-변수, 함수 등-에 대해 딱 하나의 정의만 존재해야 한다.
만약 C++ 17 이후 개발 환경이라면 inline 키워드를 붙여 헤더에 정의하면서도 외부 연결까지 가능하게끔 사용 가능합니다.
// config.h
inline constexpr int MaxSize = 100; // 여러 파일에서 공유 가능
위의 예제처럼 inline과 constexpr 키워드를 함께 사용해주면 inline 키워드로 ODR 충돌을 방지하고
constexpr로 컴파일 타임 상수임도 보장하면서 다른 추가 조치 없이 외부 연결이 가능해집니다.
+) 비트 수준 상수성과 논리적 상수성
본래 const란 절대 불변의 상수를 정의할 때 사용하는 키워드입니다.
그러나 이 상수성은 사실 비트 수준 상수성과 논리적 상수성이라는 두 개의 종류로 세부 분류할 수 있습니다.
두 상수성은 const의 의미를 기계적으로 해석할 것인가, 논리적으로 해석할 것인가의 차이입니다.
1. 비트 수준 상수성: 컴파일러의 기본 해석 방식입니다.
객체를 구성하는 메모리의 단 한 비트도 변경하지 않는 것을 의미하는 물리적인 불변성입니다.
이 규칙은 너무 엄격해서 성능 최적화를 위한 캐시 (cache)나 동기화를 위한 mutex 잠금처럼,
객체의 논리적 상태와는 무관한 내부 멤버 변수조차 수 정할 수 없다는 단점이 있습니다.
2. 논리적 상수성: 개발자가 의도하는 방식입니다.
사용자가 관찰할 수 있는 객체의 상태는 변하지 않는 것을 의미합니다.
즉, 외부에서 보이는 객체의 핵심 가치는 그대로 유지되지만, 내부적으로는 보이지 않는 데이터(캐시, 뮤텍스 등)가 변경될 수 있음을 허용하는 개념입니다.
C++에서는 mutable 키워드를 통해 이 논리적 상수성을 구현할 수 있습니다.
멤버 변수를 mutable로 선언하면, 그 변수는 const 멤버 함수 안에서도 예외적으로 수정이 허용됩니다.
이를 통해 객체의 const 계약을 지키면서도 내부적인 최적화나 자원 관리를 수행하는 유연하고 안정 적인 클래스를 설계할 수 있습니다.
4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자
[요약]
'C,C++ > [서적] 이펙티브 C++' 카테고리의 다른 글
Chapter 2. 생성자, 소멸자 및 대입 연산자 (0) | 2025.03.14 |
---|