[Effective C++] Chapter 1. C++에 왔으면 C++의 법을 따릅시다.(항목1~4)

연두비두밥·2024년 4월 29일

Effective C++

목록 보기
1/2
post-thumbnail

C++ 고수에 한걸음 나아가기 위해 오랜만에 다시한번 Effective C++을 꺼냈다.
본 포스팅은 전부 개인 공부를 위한 학습 내용을 정리한 것 입니다.

요약

  • C++은 은 여러 언어들의 연합체이다.
  • #define -> const, enum, inline(함수)로 대체해보자
  • 오류시 컴파일러를 통해 빠르게 해결할 수 있다.
  • const는 변경하면 안되는 부분에 어디든 붙이자. 그럼에도 불구하고 변경해야 한다면 mutable 키워드를 사용하자
  • const 멤버 함수의 오버로딩을 통해 비상수 멤버 함수와 상수 멤버 함수를 구현하게 된다면, 코드의 중복을 피하기 위해 비상수 멤버 함수가 상수 멤버 함수를 호출하게 구현하자
  • 대입보다 멤버 초기화 리스트 사용하자
  • 비지역 정적 객체를 지역 정적 객체로 변경해서 사용하자(유의하자)
  • 다중스레드시 지역 정적 객체의 초기화를 스레드에 맡기지말고 스레드 실행전에 미리 손으로 호출하자(경쟁상태를 막을 수 있다)

Chapter 1

C++에 왔으면 C++의 법을 따릅시다.

항목 1: C++를 언어들의 연합체로 바라보는 안목은 필수

  • C++은 초기에 단순히 C 언어에 객체 지향 기능 몇 가지가 결합된 형태였다. 처음 이름조차 '클래스를 쓰는 C(C with Classes)'
    하지만 많은 발전을 거쳤고, 오늘날의 C++은 다중패러다임 프로그래밍 언어(multiparadigm programming language)
  • 책에서는 C++을 어떻게 이해해야 잘했다고 소문이 날까라는 의문에 4가지를 알려준다. 현재 내가 가장 되고싶은 목표기도 하다. 어디가서 C++고수라고 말할만큼 이해하려면 어떻게 해야할까?
  1. C : C++의 기본은 C이다. 기본은 전부 C에서 왔다.
  2. 객체 지향 개념의 C++ : 클래스(생성자, 소멸자), 캡슐화, 상속, 다형성, 가상 함수(동적 바인딩)
  3. 템플릿 C++ : C++의 일반화 프로그래밍 부분 새로운 프로그래밍 패러다임이 파생됨 (템플릿 메타프로그래밍)
  4. STL : 템플릿 라이브러리
  • C++을 단일 언어로 바라보는 것이 아니라 이 4가지의 연합체(federation)으로 바라보자
    효과적인 프로그래밍을 하기 위해서 한 하위 언어에서 다른 하위 언어로 옮겨가면서 사용해야한다.
예를 들어보자, 
C 스타일로만 사용하고 있으면,
기본제공 타입에 대해서는
"값 전달이 참조 전달보다 대개 효율이 더 좋다"라는
규칙이 통하지만, C++의 사용자 정의 생성자/ 소멸자 
개념이 생기면서 참조에 의한 전달 방식이 더 효율적이다.
그러나 STL로 오게되면, 반복자와 함수 객체가
C의 포인터를 본떠 만든 것이라는 점을 알게 되고,
다시 값 전달 규칙이 힘을 발휘

항목 2: #define을 쓰려거든 const, enum, inline을 떠올리자

"가급적 선행 처리자보다 컴파일러를 더 가까이 하자"

Const 키워드

  • define은 컴파일러가 인식하지 못한다.
    그 이유는 컴파일러에게 넘어가기 전에 선행 처리자가 이미 바꾸어 버리기 때문이다.
    컴파일 에러가 발생하면 소스 코드와 에러 메시지가 달라 시간 낭비가 발생할 수 있다.
  • 해결법으로 매크로 대신 상수
#define ASPECT_RATION 1.653
const double AspectRation = 1.653;
  • AspectRation는 컴파일러에 의해 에러를 발견할 수있고, 기호 테이블에도 들어간다.
  • 추가적으로 코드 크기가 줄어들 수 있다. 매크로를 사용하면 선행 처리자에 의해 사본이 등장 횟수만큼 들어가지만, 상수 타입의 변수는 사본은 딱 한개만 생긴다.

Const로 상수 포인터 선언하기

만약 #define을 상수로 교체하려면 2가지만 조심하자
1. 상수 포인터(constant pointer)를 정의하는 경우
- 상수 정의는 대개 헤더 파일에 넣는 것이 상례(다른 소스 파일이 이것을 인클루드해서 사용) 포인터는 꼭 const로 선언해주어야 하며, 아울러 포인터가 가리키는 대상까지 const로 선언하는 것이 보통이다.

 const char* const authorName = "Scott Meyers"; 
 혹은
 const std::string authorName("Scott Meyers");

(책에서는 char* 기반의 문자열을 구닥다리라고 표현한다. ㅋㅋ)

Const로 클래스 멤버 상수 선언하기

  1. 클래스 멤버로 상수를 정의하는 경우
    - 상수의 사본 개수가 한 개를 넘지 못하게 하고 싶으면 정적(static) 멤버로 만들자
    class GamePlayer {
    private:
     static const int NumTurns = 5;  // 상수 선언 (정의X)
     int scores[NumTurns]; 
     ...
    }
    원래는 '정의'가 있어야 하는게 보통이다. 하지만 정적 멤버로 만들어지는 정수류 타입의 클래스 내부 상수는 예외이다. 주소를 취하지 않는한, 문제가 없으나 클래스 상수의 주소를 구한다거나 컴파일러가 '잘못 만들어진 관계'로 정의를 달라고 하면 제공해야한다.
    const int GamePlayer::NumTurns; // 정의
  • 클래스 상수의 정의는 구현 파일에 둔다. 정의에는 상수의 초기값이 있으면 안 되는데, 클래스 상수의 초기값은 해당 상수가 선언된 시점에서 바로 주어짐

    근데 대체로 나는 정의에서 초기화를 해주었던 것 같다.
    더 읽어보면 "조금 오래된 컴파일러는 위의 문법을 받아들이지 않는 경우가 종종 있습니다. 이유는 정적 클래스 멤버가 선언된 시점에 초기값을 주는 것이 대개 맞지 않다고 판단"

    class ConstEstimate {
    private:
    	static const double FudgeFactor;
     ...
     }
     .cpp
     const double cConstEstimate::FudgeFactor = 1.35;
     }

    -> 주로 내가 상수를 정의할때도 많이 쓰는 방식이다.

    나열자 둔갑술로 const보다 #define같은 상수 사용하기 (enum hack)

    const 상수와 다르게 상수의 주소를 넘겨주지 않고 해당 상수의 값을 사용하고자 할 때 쓰이는 방식
    배열의 크기를 상수로 넣었을때, 컴파일 단계에서 구식 컴파일러가 읽지 못하여 에러를 뱉을경우 대처하는 처세술

class GamePlayer
{
private:
	enum{NumTurns = 5};
    int scores[NumTurns];
};
  • 템플릿 메타프로그래밍의 핵심 기법으로서 많은 코드에서 사용됨(실제 현 프로젝트에서도 이런식으로 사용하고 있다.)

매크로 함수 보단 inline 함수 사용하기

또한 매크로 함수를 사용한 아래 예시를 보자
 #define CALL_WITH_MAX(1,b) f((a) > (b) ? (a) : (b))
 int a = 5, b = 0;
 CALL_WITH_MAX(++a, b);	// a가 두번 증가
 CALL_WITH_MAX(++a, b+10);	//a가 한번 증가
  • a :b 구문에서 f에 a를 저장하면서 연산자가 한번 더 호출
    이러한 문제를 해결하기 위해 inline 함수를 사용하자
    그렇다고 기존 매크로의 효율을 유지한채 타입 안정성을 취할 수 있다. (inline 함수는 코드를 그대로 붙여넣는다. 매크로와 동일)

정리

  • 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum
  • 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수를 생각하라

항목 3: 낌새만 보이면 const를 들이대 보자!

  • const 키워드가 붙은 객체는 외부 변경을 불가능하게 한다.

  • const는 팔방미인이다.

    char greeting[] = "Hello";
    char *p = greeting; // 비상수 포인터, 비상수 데이터
    
    const char *p = greeting; // 비상수 포인터, 상수 데이터
    
    char* const p = greeting; // 상수 포인터, 비상수 데이터
    
    const char * const p = greeting; // 상수 포인터, 상수 데이터

    이 부분 중요하다 매번 내가 헷갈린다

    const 키워드가 *표의 왼쪽에 있으면 포인터가 가리키는 대상
    const 키워드가 *표의 오른쪽에 있으면 포인터 자체가 상수

    즉,

    void f1(const widget * pw);
    void f1(widget const * pw);

    는 같다.

    const설명형태예시
    상수포인터포인터 자체가 상수 즉, 포인터 변경Xconst *const std::vector::iterator iter = vec.begin(); ++iter; //error *iter = 10; //OK
    상수데이터데이터가 자체가 상수 즉, 데이터 변경X* conststd::vector::const_iterator cIter = vec.begin(); *cIter = 10 // error ++cIter; // OK
  • 이 외에도 함수 반환값, 각각의 매개변수, 멤버 함수 앞에, 함수 전체에 대해 붙일 수도 있다.

상수 멤버 함수

중요한 이유
1. 클래스의 인터페이스를 이해하기 좋게 하기 위함
2. 이 키워드를 통해 상수 객체를 사용할 수 있게 하자

여기서 중요한 성질이 하나 나온다.
const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다

const char& operator[] (std::size_t position) const
{ return text[position]; } // 상수 객체에 대한 operator
char& operator[] (std::size_t position)
{ return text[position];} // 비상수 객체에 대한 operator

책에서도 나오지만 const 키워드가 있고 없고 차이만 있는 멤버 함수들은 오버로딩이 가능한 것을 나도 잘 모르고 지나쳤던 것 같다. 꼭 기억하자!

어떤 멤버 함수가 상수 멤버(const)라는 것은 무슨 의미일까?

  1. 비트수준 상수성(bitwise constness, 혹은 물리적 상수성)
  • 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버 제외) 그 멤버 함수가 'const'다 라는것 즉, 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안 된다는 것
    이는 컴파일러가 대입 연산을 수행했는지 보면된다. C++에서 정의하고 있는 상수성
  • 하지만 '제대로 const'로 동작하지 않는 경우도 넘어간다.
// 제대로 const 하지 않는 경우 예시
char& operator[] (std::size_t position) const
{ return pText[position]; } // 틀린것이다.

const CTextBlock cctb("Hello"); // 상수 객체 선언
 char *pc = &cctb[0]; // 상수 버전의 operator[]를 호출하여 
 // 내부 데이터에 대한 포인터를 얻음
 *pc = 'J';	// cctb는 이제 "Jello"
  1. 논리적 상수성(logical constness)
  • 위 예제와 같은 상황을 보완하는 대체 개념
  • 상수 멤버 함수라고 해서 객체의 한 비트도 수정할 수 없는 것이 아니라 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게 하면 상수 멤버 자격 OK
class CTextBlock{
public:
	...
    // mutable을 붙이면 대입 가능
    mutable std::size_t textlength;
    mutable bool lengthIsValid;
 };
 
std::size_t CTextBlock::length() const 
{
	if(!lengthIsValid)
	{
    // 상수 멤버 함수 안에서는 대입 불가
    	textlength = std::strlen(pText); // error
        lengthIsValid = true; // error
     }
     return lengthIsValid;
}

이럴때 mutable을 사용한다.

이게 여기서 나왔구나, 프로젝트하면서 처음 봤었는데 비트수준 상수성을 해결하기 위한 방안으로 나온건 몰랐다..! 알면 알수록 신기한 C++

상수 멤버 및 비상수 멤버 함수에서 코드 중복 현상을 피하는 방법

  • mutable로 모든것이 해결되진 않는다.
  • 위에 const 멤버 함수는 오버로딩이 된다고 했는데, 그럼 내부에 코드들이 중복될 것이다.

    나는 이러한 문제들을 별도의 멤버 함수(물론 나도 private)에 옮겨두고 양 버전에서 호출하면 괜찮을 것이라고 생각했다. 아니나 다를까 책에서 날 꿰뚫는듯 그래도 중복코드는 여전하고 이렇게 해도 함수 호출은 2번되며 return문 또한 중복 코드이다.라고 말한다...

  • 그래서 양 버전 중 하나만 제대로 만들고 다른 버전은 이것을 호출하는 식이 가장 좋다. (const 껍데기를 캐스팅으로_ 내 생각을 다 읽는 것 같다)
  • 몹시 흥미롭다. 코드에 중복은 프로젝트하면서 항상! 고민되는 부분인데, 여기서는 결과적으로 안정성을 잡은 비상수 operator가 상수 버전을 호출하도록 구현하자고 한다.
    코드를 보자
// 이전과 동일
const char& operator[](std::size_t position) const
{
	...
    return text[position];
}
char& operator[](std::size_t position)
{
	return const_cast<char&>(
    static_cast<const TextBlock&>(*this)[position]);
}

즉, 정리하면 비상수 operator가 상수버전을 호출하는데 static_cast로 const를 붙여준다. 이는 상수 버전을 호출하게 된다. 그리고 const를 떼어낸다.(반환타입에 캐스팅을 적용)
-> 참고 : const를 붙이는 캐스팅은 안전한 타입 변환(비상수 객체에서 상수 객체로 바꾸는)을 강제로 진행하는 것뿐이기 때문에 static_cast만 써도 딱 맞다.

정리

  • const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는데 도움을 준다.
    const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있다.
  • 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 우리는 논리적인 상수성을 사용해서 프로그래밍 해야 한다.
  • 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하자. 이때 반드시 비상수 버전이 상수 버전을 호출하게 만들자

항목 4: 객체를 사용하기 전에 반듯시 그 객체를 초기화하자

  • C++의 C 부분만을 쓰고 있으며 초기화에 런타임 비용이 소모될 수 있는 상황이라면 값이 초기화된다는 보장이 없다. C가 아닌 부분이라면 또 다르다. ex. 배열(C++의 C 부분)은 각 원소가 확실히 초기화 된다는 보장X, vector(C++의 STL 부분)는 초기화 보장
  • 여기서 중요한 것 "대입"과 "초기화"를 헷갈리지 말자
class ABEntry{
	int a;
    int b;
    int c;
};

ABEntry::ABEntry(int& _a, int& _b)
{
	a = _a;
    b = _b;
    c = 0;
}

위와 같은 방법은 "대입"이다. 기본 생성자 호출 후 복사 대입 연산자를 호출하는 방법이다.
C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전에! 초기화되어야 한다고 명시
그럼 초기화는 무엇인가? 우리가 흔히 아는 멤버 초기화 리스트이다.

ABEntry::ABEntry(int& _a, int& _b)
:a(_a),b(_b),c(0)
{}

전부 복사 생성자에 의해 초기화된다.
매개변수 없는 생성자가 있다면, 생성자에 아무런 인자도 주지 않으면 된다.

ABEntry::ABEntry()
:a(),b(),c()
{}
  • 이러한 초기화는 어떤 멤버가 초기화되지 않았는지 확인 할 수있다.

기본제공 타입의 멤버 초기화 의무

  • 상수거나 참조자로 되어 있는 데이터 멤버의 경우에 반드시 초기화 되어야한다.
    상수와 참조자는 대입 자체가 불가능

그렇다고 전부 멤버 초기화 리스트에서 초기화를 하는가?

  • 그렇지 않다.
    대입 연산을 하나의 함수에 몰아놓고, 모든 생성자에서 이 함수를 호출하는 경우도 있다.(흔히 프로젝트에서 DoInit, Initialize명으로 많이씀) 이 방법은 데이터 멤버의 진짜 초기값을 파일에서 읽어오거나, 데이터베이스에서 찾아오는 경우 유용하다.

⭐객체를 구성하는 데이터의 초기화 순서!⭐

  1. 기본 클래스는 파생 클래스보다 먼저 초기화 된다.
  2. 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화
    -> 이게 무슨말이냐면 class에 선언된 순서대로 초기화 된다는 말이다. 멤버 초기화 리스트 순서가 아니라 그래서 책에서는 '무척이나' 찾아내기 힘든 동작 버그도 피하자는 의미에서, 멤버 초기화 리스트에 넣는 멤버들의 순서도 클래스에 선언한 순서와 동일하게 맞추자.

    나는 이때까지 멤버 초기화 리스트 순서인줄 알았다.. 다들 순서대로 맞추길래 그렇게 했는데, 이런 이유가!

⚡ 하지만 여기서! 정적 객체(static)를 또 생각해야한다.

  • 정적 객체는 생성된 시점부터 프로그램이 끝날 때까지 살아있는 객체를 말한다. 메모리 영역도 스택, 힙이 아닌 데이터 영역에 속해있다.
  • 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다. (이게 무슨 말인고)
정적 객체의 범주
  1. 전역 객체
  2. 네임스페이스 유효범위에서 정의된 객체
  3. 클래스 안에서 static으로 선언된 객체
  4. 함수 안에서 static으로 선언된 객체
  5. 파일 유효범위에서 static으로 정의된 객체

추가적으로 함수 안에 있는 정적 객체는 지역 정적 객체, 그외에는 비지역 정적 객체라고 한다.

이제 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다란 무슨말일까?

  • 별도로 컴파일된 소스 파일이 두 개 이상 있으며 각 소스 파일에 비지역 정적 객체가 한 개 이상 들어있는 경우를 생각해보자. 한쪽 번역 단위에 있는 비정적 객체의 초기화가 진행되면서 다른 쪽 번역 단위에 있는 비지역 정적 객체가 사용되는데, 이 사용되는 쪽 객체가 초기화되어 있지 않을수도 있다. 이유는 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 '정해져 있지 않다'
  • 모르겠다 예시를 보자
class FileSystem{
public:
	...
};
extern FileSystem tsf;

class Directory{
public:
	Directory( params );
    ...
}
Directory::Directory( parmas )
{
	std::size_t disks = tfs.namDisks();
}
  • 이럴때 만약 Directory 객체를 생성한다면 tfs가 먼저 생성되서 초기화되지 않으면 곤란한 문제에 빠진다!
  • 이러한 문제의 해결법은 "단일체 패던(Singleton pattern)"이다.(즉, 참조자 반환)
    FileSystem을 하나만 만들어서 사용하는 것이다.

    싱글톤이 이러한 문제에서 해결법으로 나왔구나. 너무 자주 사용해서 익숙했다.

  • 하지만 만약 다중스레드라면 비상수 정적 객체는 시한폭탄이다. 한가지 방법으로는 다중스레드 돌입전 참조자 반환 함수를 전부 손으로 호출해준다. 그럼 초기화에 관계된 경쟁 상태가 없어진다.

정리

  • 기본제공 타입의 객체는 직접 손으로 초기화
  • 대입 초기화보다 진짜 초기화를 하자
    클래스에 각 데이터 멤버가 선언된 순서와 동일하게 초기화하자
  • 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제를 생각하면서 코딩하자. 비지역 정적 객체를 지역 정적 객체로 바꾸자.
profile
꾸준하고 싶은 사람

0개의 댓글