z 'C++공부/그 외의 C++' 카테고리의 글 목록 :: C++, 그래픽 기술 블로그

▶ 템플릿 템플릿 매개변수

template<typename T,
  template <typename E, typename Allocator = std::allocator<E>> class Container = std::vector>
class Grid { ... }

std::vector<<Container<std::optional<T>> mCells;

vector의 정의를 다음과 같이 수행하기 위해서 클래스 템플릿의 선언부를 붙여 넣고 클래스 이름을 매개변수 이름으로 바꾸어 저장하여, 단순 타입 이름 대신 값을 다른 템플릿의 템플릿 템플릿 매개 변수로 지정합니다. vector는 실제 타입이 아닌 템플릿이므로 class로 정의되고 실제 인스턴스화는 코드 내에서 타입을 넣어 구현해줍니다. 여기서 class Container라고 나오는데 C++17부터는 typename을 사용해도 됩니다.

▶ 템플릿 부분 특수화

템플릿의 부분 만을 특수화 할 수 있는데, 인스턴스화할 때는 모든 부분을 적어주어야만 합니다.

template <typename Tsize_t WIDTH, size_t HEIGHT>
class Grid<T, WIDTH, HEIGHT> { ... };

template <size_t WIDTH, size_t HEIGHT>
class Grid<const char*, WIDTH, HEIGHT> { ... };

Grid<int, 2, 2> tmp1;          // 그냥 템플릿
Grid<const char*, 2, 2> tmp2;  // const char* 특수화
Grid<2, 2> tmp3;               // Error!

▶ 함수 템플릿 부분 특수화

함수에 대해서는 템플릿 부분 특수화를 적용할 수 없고, 오버로딩을 통한 전체 특수화만 수행할 수 있습니다. 하지만 비슷하게는 할 수 있는데, 연역을 할 때 연역 우선 순위를 잘 이용하면 포인터에 대한 부분 특수화를 수행할 수 있습니다.

▶ exception 복제와 다시 던지기

  • exception_ptr current_exception() noexcept;
    catch 블록에서 호출하며, 현재 처리할 exception을 가리키는 exception_ptr 객체나 그 복사본을 리턴합니다. 없다면 널 exception_ptr 객체를 리턴합니다.
  • [[noreturn]] void rethrow_exception(exception_ptr p);
    이 함수는 exception_ptr 매개변수가 참조하는 Exception을 다시 던집니다. 참조한 exception을 처음 발생한 스레드에만 다시 던져야 하는 법은 없기에, 다른 스레드에 발생한 exception 처리용으로 쓰입니다.
  • template<class E> exception_ptr make_exception_ptr(E e) noexcept;
    주어진 exception 객체의 복사본을 참조하는 exception_ptr 객체를 생성합니다. 실질적으로 다음 코드의 축약 표현입니다.
    try {
      throw e;
    } catch(...) {
      return current_exception();
    }

 

▶ list의 특수 연산

splice()를 사용하면, 한 리스트를 통째로 다른 리스트에 이어붙이기를 할 수 있습니다. 이는 이중 연결 리스트로 구현되어 있어, 선형 시간으로 수행가능합니다.

▶ map

operator[]를 호출하면, 저장한 키에 대해 원소가 없더라도 원소를 새로 만들기에, find 메서드로 원하는 키에 대한 원소를 참조하는 iterator를 리턴 받아서 확인해야 합니다.

▶ 노드

 정렬 및 비정렬 연관 컨테이너를 흔히 노드 기반 데이터 구조라 불리며, C++17부터 표준 라이브러리에서 노드를 노드 핸들의 형태를 직접 접근하는 기능이 추가됐습니다. extract 메서드에 반복자 위치나 키를 지정해서 호출하면 연관 컨테이너에서 노드를 노드 핸들로 가져올 수 있습니다. extract로 컨테이너에서 노드를 가져오면 그 노드는 컨테이너에서 삭제됩니다. 이를 insert를 통해 다른 곳으로 추가할 수도 있습니다. extract를 통해 노드 핸들을 가져와서 추가하면 복제나 이동 연산을 수행하지 않고도 한쪽 연관 컨테이너에 있는 데이터를 다른 쪽 연관 컨테이너로 옮기는 효과를 볼 수 있습니다.

 merge를 이용하면 한쪽 연관 컨테이너에 있는 노드를 모두 다른 쪽으로 이동시킬 수 있습니다. 대상 컨테이너의 노드와 중복되거나 다른 이유로 이동시킬 수 없는 노드는 원본 컨테이너에 남습니다.

▶  find_if

find_if는 인수로 검색할 원소가 아닌 predicate function callback을 받아 true나 false 형태로 값을 판단하여, 지정한 범위 안에 있는 인수로 지정한 predicate가 True를 리턴할 까지 계속 호출 합니다. 즉 조건에 맞는 값이 처음 나올 때를 찾아줍니다.

▶ accumulate 알고리즘

첫 번째 인자, 두 번째 인자에 범위를 지정해주고, 3번쨰는 초기값을 넣어주고, 4번쨰는 넣지 않으면 덧셈, 아니면 함수를 인자로 받아 원하는 식으로 더하는 것이 가능합니다.

▶ lambda mutable

람다에서 변수를 가져올 때 특성 그대로 가져오기에 const 속성이 있다면 이를 받아옵니다. 또한 함수 자체가 const로 지정되어 있기에 만약 수정하고 싶다면 mutable을 활용하면 됩니다. 람다 캡쳐 표현식을 사용할 떄는 const 속성이 존재하더라도, 복제 방식으로 촉히ㅘ하기에 const 지정자가 사라집니다.

double data = 1.23;
auto lam = [data]() mutable { data*= 2; };

▶  함수 객체

람다 함수 혹은 std::function 처럼 사용 가능합니다.

  • 산술 함수 객체 - multiplies<int>(), dividies<>(), +나 -같은 연산을 지원합니다.
  • 비교 함수 객체 - equl_to, less, greater 등...
  • 논리 함수 객체 - logical_not(operator!) 등등...
  • 비트 연산 함수 객체 - bit_and(operator&) 등등...

▶  부정 연산자(not_fn) (C++17)

부정 연산자는 std::bind와 비슷하지만, 호출 가능 개체의 결과를 반전시킨다는 점이 다릅니다. not_fn으로 함수 객체를 감싸면 됩니다.

▶ 멤버 함수 호출하기

메서드 포인터를 호출하는 코드는 원래 일반 함수 포인터를 호출하는 것과는 달리 반드시 객체의 문맥에서 호출되어야 하는데, mem_fn()이라는 변환 함수를 통해, &Class::로 명시만 해준다면 사용 가능합니다.

▶ invoke() (C++17)

사용하면 모든 종류의 호출 가능 개체에 매개변수를 지정해서 호출할 수 있습니다. 이는 템플릿 코드를 작성할 때 굉장히 유용합니다.

invoke([]()const auto& msg) { cout << msg << endl; }, "Hello, invoke."};

▶ 집계 알고리즘

all_of, any_of, none_of, count 등을 통하여 앞에 두 인자는 범위, 뒤에 인자는 boolean 형식의 판별식을 통해 이를 확인할 수 있습니다.

▶ transform

// 1, 2번째 범위의 값을 람다 함수를 적용하여, 3번쨰 위치에 넣습니다.
transform(begin(myVector), end(myVector), begin(myVector),
	[](int i) { return i + 100; };); 

// 두 개의 벡터의 값을 람다 함수 적용하여 첫번째 위치에 넣습니다.
transform(begin(vec1), end(vec1), begin(vec2), end(vec2),
		[](int i, int j) { return i + j; };);

 

▶ 중첩된 exception

앞서 발생한 Exception을 처리하는 도중에 또 다른 에러가 발생해서 새로운 Exception이 전달될 수도 이씁니다. 이렇게 중간에 exception이 발생하면 현재 처리하고 있던 Exception 정보가 사라집니다. 이를 해결하기 위해서 먼저 잡은 exception을 새로 발생한 exception의 문맥 안에 포함시키는 중첩된 익셉션이라는 기능을 제공합니다. 어떤 exception을 처리하는 도중에 catch 문에서 새로운 exception을 던지고 싶다면 std::throw_with_nested()를 사용하면 됩니다. 나중에 발생한 exception을 처리하느 catch 문에서 먼저 발생했던 exception에 접근할 때는 dynamic_cast를 사용해주면 됩니다.

class Myexception :: public std::exception
{
  public:
    MyException(string_view message) : mMessage(message) {}
    virtual const char* what() const noexcept override {
      return mMessage.c_str();
    };
  private:
    string mMessage;
};

void doSomething()
{
  try {
    throw runtime_error("Throwing a runtime_error exception");
  } catch (const runtime_error& e) {
    thorw_with_nested(MyException("Hello"));
  };
];

int main()
{
  try {
    doSomething();
  } catch (const MyException& e) {
    const auto* pNested = dynamic_cast<const nested_exception*>(&e);
    if (pNested) {
      try {
        pNested->rethrow_nested();
      } catch (const runtime_error& e) {
        cout << e.what() << endl;
      }
   }
};

다음과 같이 main함수에서 dynamic_cast를 활용하여 중첩된 익셉션의 여부를 확인하고 있다면, rethrow_if_nested를 통해 다시 한번더 처리하도록 합니다.

▶  익셉션 다시 던지기

다시 던지는 방법으로, throw; 와 잡은 익셉션 e를 throw e;로 다시 던지는 방법이 존재하는데, throw를 그대로 사용한다면, 예상대로 행동하지만, Throw e를 사용한다면 문장에서 슬라이싱이 발생하여 변경될 여지가 생기므로, thorw;를 사용해야 합니다.

▶  생성자 exception

생성자에서 exception이 발생하여 생성자를 벗어나면, 그 객체에 대한 소멸자가 호출되지 않습니다. 따라서 생성자에서 예외가 생길 여지가 있다면 이에 대해 처리를 해주는 코드를 새로 짜야만 합니다. 또한 생성자를 위해서 함수 try이란 기능이 존재하며 다음과 같습니다.

MyClass:MyClass()
try : <생성자 이니셜라이저>
{
  ...
}
catch (const exception& e)
{
  ...
}

이는 다음과 같은 특성을 갖습니다

  • catch 문은 생성자 이니셜라이저나 생성자 본문에서 발생한 익셉션을 잡아서 처맇바니다.
  • 현재 발생한 익셉션을 다시 던지거나 새 익셉션을 만들어서 던져야 합니다. catch 문에서 이렇게 처리하지 않으면 런타임이 자동으로 현재 익셉션을 다시 던집니다.
  • catch 문은 생성자에 전달된 인수에 접근할 수 있으며, 익셉션을 잡으면, 생성자의 실행을 정상적으로 마친 베이스 클래스나 그 객체로 된 멤버는 catch문을 시작하기 전에 소며로딥니다.
  • catch 문 안에서는 객체로 된 멤버 변수에 접근하며 안되며, 이는 소멸되었기 때문입니다. 하지만 논클래스 타입(일반 포인터) 데이터 멤버에 대해서는 따로 정리를 해주어야만 합니다.
  • catch 문 안에서 생성자는 원래 아무것도 리턴하지 않기에 return은 사용 불가능 합니다.

따라서 함수 try 블록은 제한된 상황에서만 사용할 수 있습니다.

  • 생성자 이니셜라이저에서 던진 익셉션을 다른 익셉션으로 변환할 때
  • 메세지를 로그 파일에 기록할 때
  • 생성자 이니셜라이저에서 할당한, 소멸자로 자동 제거할 수 없는 리소스를 익셉션을 던지기 전에 해제할 때

▶  arity

arity는 연산자의 인수 또는 피연산자의 개수로, arity를 변경할 수 있는 곳은 함수 호출, new, delete 연산자 뿐입니다.

▶ operator->

smart->set(5);
(smart.operator->())->set(5);

template<typename T>
T* Pointer<T>::operator->() { return mPtr; );

▶ 명시적 변환 연산자로 모호한 문제 해결하기

operator double()과 같이 변환 연산자를 추가하면 모호함이 발생할 수 있습니다.

SpreadsheetCell cell(1.23);

double d2 = cell + 3.3; // Error!

이는 아래 둘 중 변환 중 선택을 하지 못하게 되기 때문입니다.

  • cell을 double로 변환 -> 3.3 더하기
  • 3.3을 SpreadsheetCell로 변환 -> SpreadsheetCell 형태로 더하기 -> double로 변환

따라서, 이를 explicit operator double()을 통해 명시적으로 SpreadsheetCell에서 double변환을 하도록 하여 모호한 상태를 막습니다.

▶ operator void*()

nullptr과의 비교, bool 표현식을 사용하게 되는 경우, operator void*()를 통해 비교 가능합니다. operator bool()의 경우는 nullptr과의 비교가 불가능 하기 때문에, 포인터를 반환할 때는 void*()가 더 낫습니다.

▶ new 표현식

new 표현식은 여섯 가지 종류가 있으며, 다음과 같은 종류가 존재 합니다.

void* operator new(size_t size);
void* operator new[](size_t size);
void* operator new(size_t size, const std::nothrow_t&) noexcept; // new(nothrow)
void* operator new[](size_t size, const std::nothrow_t&) noexcept; // new(nothrow)[]

나머지 두 개는 실제로 객체를 할당하지 않고 기존에 저장된 객체의 생성자를 호출만 하는 특수한 형태의 new 표현식으로 배치 new 연산자라 부르며, 일반 변수 버전과 배열 버전이 존재합니다. C++ 표준에서는 다음 두 가지 operator new에 대한 오버로딩을 금지하고 있습니다.

void* operator new(size_t size, void* p) noexcept;
void* operator new[](size_t size, void* p) noexcept;

▶ delete 표현식

직접 호출 가능한 delete 표현식은 두개 이지만, operator delete는 6가지로, nothrow 버전과 배치 버전 두개는 생성자에서 익셉션이 발생할 때에만 사용되어, 생성자를 호출하기 전에 메모리를 할당하는데 사용했던 operator new에 대응되는 operator delete가 호출됩니다.

▶ operator new, delete 매겨변수 추가하여 오버로딩

operator new를 표준 형태 그대로 오버로딩할 수 있을 뿐만아니라 매개변수를 원하는 형태로 추가하여 오버로딩하는 것도 가능합니다.

▶ 오버라이드한 메서드의 속성 변경하기

대부분 메서드의 구현을 변경하기 위해 오버라이드 하지만, 메서드를 오버라이드하는 과정에서 메소드의 속성을 바꾸고 싶을 때도 존재합니다. 리턴 타입은 한정된 범위에 대해서 변경이 가능한데, C++ 베이스 클래스의 리턴 타입이 다른 클래스에 대한 포인터나 레퍼런스 타입이면 메서드를 오버라이드할 때 리턴 타입을 그 클래스의 파생 클래스에 대한 포인터나 레퍼런스 타입으로 바꿀 수 있으며 이러한 타입을 공변 리턴 타입(covariant return type)이라고 합니다. 베이스 클래스와 파생 클래스가 병렬 계층을 이룰 때, 기능이 유용 합니다. 다음의 예시를 봅시다

Cherry* CherryTree:pick()
{
  return new Cherry();
};

BingCherry* BingCherryTree:pick()
{
  return new BingCherry();
};

BingCherry는 Cherry의 상속을, BinCherryTree는 CherryTree의 상속을 받는다고 한다면, 리턴 타입을 변경해도 문제가 발생하지 않는다는 리스코프 치환 원칙(모 객체와 이를 상속한 자식 객체가 있을 때 부모 객체를 호출하는 동작에서 자식 객체가 부모 객체를 완전히 대체할 수 있다)만 지켜진다면, 오버라이딩 가능합니다. 하지만 전혀 관련 타입으로 변경할 수는 없습니다. 즉, unique_ptr<BingCherry>형식은 감싸고 있어 unique_ptr<Cherry> 형식과 무관하다고 판단되기에 이는 사용 불가합니다.

▶ 생성자 상속

using 키워드를 사용하면 베이스 클래스에 정의된 메서드를 파생 클래스에 지정할 수 있습니다. 이는 생성자에도 적용 가능합니다.

class Base
{
  public:
    virtual void someMethod();
    virtual ~Base() = default;
    Base() = default;
    Base(std::string_view str);
};

Class Derived : public Base
{
  public:
    Derived(int i);
    virtual void someMethod(int i);
    
    using Base::someMethod; // 명시적 상속
    using Base::Base;       // 명시적 상속, string_view;
};

첫 번째로 Base::someMethod가 없다면, Derived에서 사용하는 someMethod는 상속받은 것이 아니라, 네임 스페이스에서 Base의 함수를 이름으로 가리게 되어, 인자가 없는 someMethod는 사용이 불가합니다. 따라서 Base::someMethod를 사용해주기 위해서, 명시적으로 상속받는 방법이며, 이는 생성자에도 같아,  default를 제외한 생성자들을 상속합니다. 따라서 Derived는 string_view를 받는 식으로 생성자를 호출 가능하게 됩니다. 파생 클래스에서 베이스 클래스의 생성자를 상속할 때, 파생 클래스에서 새로운 멤버 변수를 초기화해야 한다면 사용할 때, 그 멤버 변수에 대해서 멤버 초기화자를 통한 초기화가 필요시 됩니다. 또한 같은 형태의 함수나 생성자가 존재한다면using으로 가져온 생성자나 함수보다 우선시되어 작동됩니다. 또한 일부만 상속할 수도 없습니다.

▶ 베이스 클래스가 static인 경우

C++에서는 static 메서드를 오버라이드할 수 업습니다. 또한 static과 virtual을 동시에 지정할 수 없어, static 메서드를 오버라이드하면 원래 의도와 다른 효과가 발생합니다. 파생 클래스에 있는 static 메서드의 이름이 베이스 클래스의 static 메서드와 같으면 서로 다른 메서드 두 개가 생성됩니다. static 메서드는 클래스에 속하기 때문에 이름이 같더라도 각자 클래스에 있는 메서드 코드가 호출됩니다. C++에서 static 메서드를 호출할 때는 실제로 속한 객체를 찾지 않고, 컴파일 시간에 지정된 타입만 보고 호출할 메서드를 결정합니다.

▶ 베이스 클래스 메서드가 오버로드된 경우

위에서 설명한 것 중 오버로드된 메서드 중 일부만 오버라이딩 되었을 때, 이를 using을 통해 명시적으로 선언해주지 않으면, 나머지에 대해서 이름이 가려져 실행하지 못한다고 하였습니다. 하지만 이는 자식 클래스 객체를 가리킬 변수를 업캐스팅한 포인터라 레퍼런스를 활용하면 됩니다.

#include <iostream>

using namespace std;
class Base
{
	public:
		virtual ~Base() = default;
		virtual void overload() { cout << "Base's overload()" << endl; };
		virtual void overload(int i) { cout << "Base's overload(int i)" << endl; };
};

class Derived : public Base
{
	public:
		virtual void overload() override { cout << "Derived's overload()" << endl; };
};

int main() {
	Derived myDerived;

	Base& ref = myDerived;
	ref.overload(7);         // Good!
	myDerived.overload(7);   // Error!
}

이는 아마 가상 테이블 함수에서 찾아가기 때문에 다르게 작동하는 것 같습니다.

▶ private나 protected로 선언된 베이스 클래스 메서드

private이나 protected의 경우 메서드의 호출할 수 있는 대상을 제한할 뿐이지, 파생 클래스에서 부모 클래스의 private 메서드를 호출할 수 없다고 해서 오버라이드도 할 수 없는 것은 아닙니다.

class A
{
  public:
    virtual int getTotal() const { return getMoney() * getPeople(); };
    virtual int getMoney() const { return 50; };
  private:
    virtual int getPeople() const { return 4; };
};

class B : public A
{
  private:
    virtual int getPeople() const override { return 5; };
};

int main()
{
  A a;
  a.getTotal(); // 200;
  B b;
  b.getTotal(); // 250;
};

위와 같이 private의 메서드를 오버라이딩하여서 동작을 바꾸어 행동하게 만들었습니다. 

▶ 베이스 클래스 메서드와 접근 범위를 다르게 지정하는 경우

 이는 크게 범위를 좁히는 경우와 넓히는 경우로 나뉘어지는데, 좁히는 경우는 부모에서 public으로 지정하였다가 자식에서 private으로 지정하는 것인데, 이는 업캐스팅하여 해당 메서드를 실행하는 경우 인터페이스는 부모 인터페이스를 사용하지만, 함수는 가상 테이블을 거쳐 가상함수 테이블에서 자식 메서드를 사용합니다. 따라서 접근 범위 제한이 완벽하게 적용되지는 않습니다.

 범위를 넓히는 경우는, 부모에서 private으로 선언된 함수를 자식에서 public 함수에서 부르거나, 오버라이딩하는 방법이 존재합니다. 접근 범위만 바꾸고 싶다면 public에 using을 활용하여 명시적으로 상속할 수 있습니다.

▶ 파생 클래스의 복제 생성자와 대입 연산자

자식 클래스에서 명시적으로 복제 생성자를 정의하면 부모 클래스의 복제 생성자를 호출해야 하며, 사용하지 않으면 디폴트 생성자가 불립니다. 마찬가지로 파생 클래스에서 operator=을 오버라이드 하면 객체의 일부만 대입 연산을 적용하는 경우를 제외하면, 부모 버전의 대입 연산자도 호출해야 합니다.

Derived::Derived(const Derived& src) : Base(src) {};
Derived::operator=(const Derived& src)
{
  Base::operator=(src);
  return *this;
};

▶ 실행 시간 타입 정보

  • RTTI(Run Time Type Information)은 실행 시간에 객체를 들여다보는 기능을 제공합니다.
  • vtable이 없는 클래스에 대해 dynamic_cast()를 호출하면 컴파일 에러가 발생합니다.
  • typeid를 이용하여 객체의 타입을 런타임에 알아낼 수 있습니다.

▶  사용자 정의 리터럴

언더스코어 바로 다음에 나오는 첫 문자가 소문자로 나오면서 리터럴 연산자를 활용하여 가공 모드(cooked mode)로 작동됩니다.

std::complex<long double> operator"" _i(long double d)
{
  return std::complex<long double>(0, d);
}

▶  __has_include (C++17)

__has_include("파일명")을 통해 특정 헤더 파일이 포함되어 있는 지를 확인할 수 있습니다.

 

'C++공부 > 그 외의 C++' 카테고리의 다른 글

전문가를 위한 C++ : 16 ~ 18장  (0) 2022.07.14
전문가를 위한 C++ : 13 ~ 15장  (0) 2022.07.14
전문가를 위한 C++ : 4 ~ 9장  (0) 2022.07.12
전문가를 위한 C++ : 1 ~ 3장  (0) 2022.07.11
Optimized C++ 9 - 13장  (0) 2022.07.08

전체를 다 보기에는 양이 많고, 아는 부분도 많아 필요한 부분만 정리하도록 하겠습니다.

▶ C 스타일 캐스팅과 정적 캐스팅

C 스타일 캐스팅을 활용하면, 포인터 사이의 크기는 서로 같기에 변환은 되지만, 관련 없는 데이터 타입으로 포인터를 캐스팅하는 경우가 발생할 수도 있습니다. 이를 정적 캐스팅인 static_cast<>를 사용하면 컴파일 에러를 발생시킬 수 있습니다. 정적 캐스팅하려는 포인터와 캐스팅 결과에 대한 포인터가 가리키는 객체가 서로 상속 관계에 있다면 컴파일 에러가 발생하지 않는데, 상속 관계에 있는 대상끼리 캐스팅할 때는 dynamic_cast<>인 동적 캐스팅을 사용하는 것이 더 안전합니다.

▶ wide string

wchar_t 타입은 유니코드 문자를 지원하는 문자 타입 중 하나로, 대체로 크기가 한 바이트인 char 타입보다 큽니다. string literal이 wide string이라는 것을 컴파일러에 알려주려면 그 앞에 L을 붙여야 합니다.

const wchar_t* myString = L"Hello, Wolrd";

▶ unique_ptr의 커스텀 삭제자

기본적으로 unique_ptr은 메모리 할당 및 해제를 new와 delete로 수행하는데, 다음과 같은 방식으로 변경할 수도 있습니다.

int *malloc_int(int _value) {
  int* p = (int*)malloc(sizeof(int));
  *p = value;
  return p;
};

int main()
{
  unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
  return 0;
}

malloc_int를 활용하여 메모리를 할당할 수 있도록 하며, 소멸자를 위하여 먼저 템플릿 인자로 함수의 타입을 decltype으로 확인한 후 이에 대한 포인터를 이용하여 함수 포인터를 구성하고, 함수 인자로 포인터를 넘겨주었습니다.

▶ shared_ptr - Aliasing

앨리어싱(aliasing)이란 한 shared_ptr이 한 포인터를 다른 shared_ptr과 공유하면서 다른 객체를 가리킬 수 있게 하는 기능입니다.

class Foo {
  public:
    Foo(int value) : mData(value) {};
    int mData;
};

auto foo = make_shared<Foo>(42);
auto aliasing = make_shared<int>(foo, &foo->mData);

여기서 두 shared_ptr이 모두 삭제될 때만 Foo 객체가 삭제됩니다. 소유한 포인터에 대해서는 레퍼런스 카운팅에 사용하는 반면, 저장된 포인터는 포인터를 역참조 하거나 그 포인터에 대해 get 호출을 할 때 리턴됩니다. 저장된 포인터는 대부분의 연산에 적용할 수 있습니다.

▶ enable_shared_from_this

믹스인 클래스인 std::enable_shared_from_this를 이용하면 객체의 메서드에서 shared_ptr이나 weak_ptr을 안전하게 처리할 수 있습니다. enable_shared_from_this 믹스인 클래스는 다음 두 개의 메서드를 클래스에 제공합니다.

  • shared_from_this() : 객체의 소유권을 공유하는 shared_ptr 리턴
  • weak_from_this() : 객체의 소유권을 추적하는 weak_ptr 리턴 (C++17)
class Foo : public enable_shared_from_this<Foo>
{
  public:
    shared_ptr<Foo> getPointer() {
      return shared_from_this();
    }
};

int main() {
  auto ptr1 = make_shared<Foo>();
  auto ptr2 = ptr1->getPointer();
}

여기서 객체 포인터가 shared_ptr에 이미 저장된 상태에서만 객체에 shared_from_this()를 사용할 수 있다는 점에 주의해야 합니다. 예제의 main을 보면 make_shared로 Foo 인스턴스를 담은 shared_ptr인 ptr1을 생성했고, Foo 인스턴스에 대한 shared_from_this를 호출할 수 있게 되었습니다. getPointer()에서 shared_ptr를 리턴하면 중복 삭제가 발생합니다.

▶ 클래스 멤버 변수 생성 순서

생성자 이니셜라이저를 사용할 때 주의할 점으로, 나열한 데이터 멤버가 이니셜라이저에 나열한 순서가 아닌 클래스 정의에 작성한 순서대로 초기화 된다는 점입니다. 밑에 예시를 통해 알아보겠습니다.

class Foo {
  public:
    Foo(double value) : mValue(value)
    {
      cout << "Foo::mValue = " << mValue << endl;
    }
  private:
    double mValue;
};

class MyClass1 {
  public:
    MyClass(double value) : mValue(value), mFoo(mValue)
    {
      cout << "MyClass1::mValue = " << mValue << endl;
    }
  private:
    double mValue;
    Foo mFoo;
};

class MyClass2 {
  public:
    MyClass(double value) : mValue(value), mFoo(mValue)
    {
      cout << "MyClass2::mValue = " << mValue << endl;
    }
  private:
    Foo mFoo;
    double mValue;
};

int main () {
  MyClass1 a(1.2); // Foo::mValue = 1.2
                   // MyClass1::mValue = 1.2 
  MyClass2 b(1.2); // Foo::mValue = -9.25596e+61
                   // MyClass1::mValue = 1.2
};

위와 같이 private 내의 변수 위치를 바꾸는 것으로 생성의 순서가 바뀌어서 mFoo는 mValue를 통해  초기화를 하지만 mValue의 값이 정해지지 않았기에 이상한 값이 나옵니다. 따라서 클래스 정의에 나온 순서와 생성자 이니셜라이저에 나온 순서를 맞추는 것이 좋으며, 그렇지 않다면 컴파일러에서 경고 메세지를 출력할 수도 있습니다.

▶ 위임 생성자

위임 생성자를 사용하면 같은 클래스의 다른 생성자를 생성자 안에서 호출할 수 있습니다. 하지만 생성자 안에서 다른 생성자를 직접 호출할 수 없기에, 반드시 생성자 이니셜라이저에서 호출해야하며, 멤버 이니셜라이저 리스트에 이것만 적어야 합니다.

SpreadsheetCell::SpreadsheetCell(string_view initialValue)
  SpreadsheetCell(stringToDouble(initialValue)) {};

▶ 컴파일러가 생성하는 생성자

복사 생성자는 명시적으로 정의하지 않는 한 컴파일러는 무조건 복제 생성자를 만들어 주며, 어떤 생성자라도(복사 생성자 포함) 정의했다면 컴파일러는 디폴트 생성자를 만들지 않습니다. 하지만 C++11부터 복제 대입 연산자 혹은 소멸자가 존재한다면 복제 생성자를 생성해주지 않고, 복제 생성자나 소멸자가 있으면 복제 대입 연사자를 생성해주지 않습니다.

▶ const 기반 오버로딩

const를 기준으로 오버로딩할 수 있어, const 객체에서는 const 메서드를, non-const 객체에서는 non-const 메서드를 실행합니다. 간혹 const 버전과 non-const 버전의 구현 코드가 똑같을 때가 존재하는데, 코드 중복을 위해서 const_cast 패턴이 사용 가능합니다.

class Spreadsheet
{
  public:
    SpreadsheetCell& getCellAt(size_t x);
    const SpreadsheetCell& getCellAt(size_t x) const;
};

const SpreadsheetCell& Spreadsheet::getCellAt(size_t x) const
{ return mCells[x]; }

SpreadsheetCell& Spreadsheet::getCellAt(size_t x)
{ return const_cast<SpreadsheetCell&>(std::as_const(*this).getCellAt(x)); }

std::as_const 함수는 C++17부터 추가되어, 이전 버전에서는 static_cast가 활용 가능합니다.

▶ 명시적으로 오버로딩 제거하기

class MyClass
{
  public:
    void foo(int i);
    void foo(double i) = delete;
};

다음과 같이 인자를 int로 받지만 double로 받는 경우 암시적 변환을 통해 코드가 수행되는 것을 막기 위해서 double로 인자를 받는 함수를 만들어 처리하되 이를 delete로 명시적으로 제거하여 컴파일 에러가 발생하도록 만듭니다.

▶ 인라인 코드

인라인 메서드를 호출하는 코드에서 이를 정의하는 코드에 접근해야만 컴파일러가 메서드 호출 부분을 본문에 나온 코드를 대체할 수 있기 때문에, 인라인 메서드는 반드시 프로토타입과 구현 코드를 헤더 파일에 작성합니다.

 

'C++공부 > 그 외의 C++' 카테고리의 다른 글

전문가를 위한 C++ : 13 ~ 15장  (0) 2022.07.14
전문가를 위한 C++ : 10 ~ 12장  (0) 2022.07.13
전문가를 위한 C++ : 1 ~ 3장  (0) 2022.07.11
Optimized C++ 9 - 13장  (0) 2022.07.08
Optimized C++ : 4 ~ 8장  (0) 2022.07.07

전체를 다 보기에는 양이 많고, 아는 부분도 많아 필요한 부분만 정리하도록 하겠습니다.

▶ if, switch 문의 이니셜라이저 (C++17)

if 문과 switch문 안에 이니셜라이저를 넣는 기능이 추가되었습니다.

if (<이니셜라이저> ; <조건문>) { <본문> }
switch (<이니셜라이저> ; <표현식>) { <본문> }

위와 같은 형태로 주어지며, if문의 조건문 혹은 switch문의 표현식 및 본문에서 이를 사용할 수 있습니다.

▶ [[fallthorugh]] (C++17)

attribute에 해당하며, switch 문에서 폴스루 구문을 발견했는데 케이스가 비어 있지 않다면, 경고 메시지를 발생시킵니다. 이를 막기 위해 [[fallthrough]] 구문을 적어 방지합니다.

switch (backgroundColor) {
  case Color::DarkBlue;
    doSomething1();
    [[fallthrough]];       // 경고 메세지 방지
  case Color::DarkBlue;
    doSomething2();
    break;
  case Color::DarkBlue;
    break;
};

▶ 현재 함수 이름

함수에는 내부적으로 __func__이라는 로컬 변수가 정의되어 있으며, 이 변수는 현재 함수의 이름을 값으로 가지고 있어 활용 가능합니다.

▶ C 스타일 배열

초기화를 할 때 initializer_list로 초기화를 하면, 배열의 크기가 컴파일러가 알아서 지정해줄 수도 있고, 배열의 크기가 정해져있다면, initializer_list에 나온 원수 수만큼 초기화를 해주고 나머지는 0으로 초기화시켜줍니다. C++17부터는 std::size로 C 스타일 배열의 크기를 알아낼 수 있습니다. 그 전에는 sizeof로 사이즈를 구한 뒤 자료형 크기로 나누어 구하였습니다.

int myArray[3] = {2}; // {2, 0, 0}과 동일
unsigned int arraySize = std::size(myArray); // 3

▶ 구조적 바인딩 (C++17)

구조적 바인딩을 이용하면 여러 개의 변수를 선언할 때 배열, 구조체, 페어 또는 튜플의 값으로 초기화할 수 있습니다. 변수 선언과 동시에 구조적 바인딩을 통해 값을 할당할 때는 변수의 타입이 아닌 반드시 auto를 사용해야합니다. 구조적 바인딩은 표현식 값 개수가 반드시 일치해야하며, 배열 뿐만 아니라 모든 멤버가 Non-static이면서도 public으로 선언된 데이터 구조라면 어떤 것도 적용할 수 있습니다.

std::array<int, 3> values = { 11, 22, 33};
auto [x, y, z] = values;

struct Point {double mX, mY, mZ;};
Point point{1.0, 2.0, 3.0};
auto [x, y, z] = point;

▶ 범위 기반 for 문

C 스타일의 루프, initializer_list 및 STL처럼 iterator를 리턴하는 std::begin(), std::end() 메서드가 정의된 모든 타입에 적용이 가능합니다.

▶ shared_ptr 배열 (C++17)

shared_ptr에 배열도 저장할 수 있지만, 배열을 저장하는 shared_ptr을 생성할 때는 make_shared<>()를 사용할 수 없고, 다음과 같이 작성해야 합니다.

shared_ptr<Employee[]> employees(new Employee[10]);

▶ 유니폼 초기화

C++11 이전에는 구조체와 클래스의 타입 변수 초기화 방법이 달랐습니다.

Struct a = {10, 10, 2.5};
Class b(10, 10, 2.5);

C++11 이후로는 타입을 초기화할 때 {...} 문법을 사용하는 유니폼 초기화를 따르도록 통일 됐습니다. 중괄호로 빈 집합 표시를 해주면 제로 초기화를 할 수도 있습니다. 유니폼 초기화를 사용하면 축소 변환을 방지할 수 있는데, 이는 float가 int로 변환 되어 초기화 되는 것을 방지시킬 수 있습니다.

void func(int i) {};

int main()
{
  int x = {3.14}; // Error!
  func({3.14});   // Error!
  func(3.14);     // OK! But, Warning;
};

 

▶이니셜라이저 리스트 초기화

이니셜라이저는 다음과 같이 두개가 존재합니다.

  • 복제 리스트 초기화 : T obj = {arg1, arg2, ...};
  • 직접 리스트 초기화 : T obj{arg1, arg2, ...};

C++17부터 auto 추론 기능과 관련하여 크게 달라 졌는데, C++17 이전에는 둘다 initializer_list<>로 처리 되었지만, 이후에는 auto는 직접 리스트 초기화에 대해 값 하나만 추론하게 되었습니다. 또한 복제 리스트 초기화에서 중괄호 안에 나온 원소는 반드시 타입이 모두 같아야만 합니다.

auto a = {11};      // initializer_list<int>
auto b = {11, 22};  // initializer_list<int>

auto a{11};         // int!!
auto b{11, 22};     // 원소가 너무 많다는 에러 발생

▶ literal pooling

Literal pooling이란 string literal은 내부적으로 메모리의 읽기 전용 영역에 저장되는데, 컴파일러는 같은 string literal이 코드에 여러 번 나오면 그 중 한 string에 대한 레퍼런스를 재사용하는 방식으로 메모리를 절약합니다. 즉 hello가 500번 나와도 메모리 공간을 딱 하나만 할당합니다. 또한 string literal은 메모리의 읽기 전용 영역에 존재하므로, const char가 n개인 배열로 타입을 지정하여 변경할 수 없도록 합니다.

▶ raw string litral

문자열 표기에 있어 "만 사용한다면 \t, \n과 같은 이스케이프 시퀀스를 사용해야기 때문에, 안의 문자를 문자 그대로 받아들이는 raw string literal이 존재합니다. 하지만 이는 안에 )"를 표기할 수 없기에, extended string literal이 존재하는데 이는 R"*( ... )*"의 형태로 *에는 최대 16문자로 표현할 문자열에 들어가 있는 문자열이 아니면 무엇이든 들어가도 괜찮으며, 고유한 구분자 시퀀스를 앞뒤에 두는 것을 통해 표현가능합니다. 밑의 예시는 *에 -를 넣은 경우 입니다. 

const char *str = R"(Is this? )";
const char *str = R"-(Is this? )-";

▶ std::string literal

표준 사용자 정의 리터럴 s를 사용하려면 using namespace std::string_literals; 또는 using namespace std;가 필요합니다.

auto string1 = "Hello Wolrd";  // const char*
auto string1 = "Hello Wolrd"s; // std::string

▶ std::string_view (C++17)

C++17 이전에는 읽기 전용 스트링을 받는 함수의 매개변수 타입을 쉽게 결정할 수 없었습니다. const char*로 지정하면, std::string에서 c_str() 혹은 data()를 이용하여 string을 const char*로 변환해서 호출해야 했습니다. 이를 해결한 것이 string_view로 string_view는 const string& 대신에 사용 가능하며, 스트링을 복사하지 않아 오버헤드도 존재하지 않습니다. 

 string과 string_view는 서로 연결/결합할 수 없습니다. 결합하려면 .data()를 통해 바꾸어 합쳐줘야 합니다. std::string은 값 전달하는 경우 이를 복사해서 오버헤드가 생기는 반면, string_view는 길이와 포인터만 가지고 있기에, 참조 전달에 가깝습니다. 이는 string, const char*, literal에 대해 모두 오버헤드 없이 만족스럽게 참조 전달을 진행할 수 있습니다.

 string_view를 사용하는 것만으로는 string이 생성되지 않기에 명시적으로 string으로 변환하거나, data를 통해 변환될 수 있도록 해주어야 합니다. string_view 또한, 리터럴 sv를 통해 string_view literal이 구현가능하며, using namespace std::string_literals; 또는 using namespace std;가 필요합니다.

 

 

'C++공부 > 그 외의 C++' 카테고리의 다른 글

전문가를 위한 C++ : 13 ~ 15장  (0) 2022.07.14
전문가를 위한 C++ : 10 ~ 12장  (0) 2022.07.13
전문가를 위한 C++ : 4 ~ 9장  (0) 2022.07.12
Optimized C++ 9 - 13장  (0) 2022.07.08
Optimized C++ : 4 ~ 8장  (0) 2022.07.07

Chapter 9. 검색 및 정렬 최적화

C++ 프로그램은 검색을 많이 하는데, 이 번장에서는 테이블에서의 검색을 최적화하는 방법을 알아보겠습니다. C++에서는 std::map을 사용하여 검색을 하는데 이는 키/값 테이블이라고 합니다.

9.1 std::map과 std::string을 사용한 키/값 테이블

  • 값 타입은 검색 성능에 영향을 미치지 않습니다. 만일 엄청나게 크다면 캐시 성능을 저하시킬 수는 있습니다.
  • 키 타입은 문자열이나 std::string이 일반적으로 채택됩니다.

9.2 검색 성능 향상을 위한 툴킷

  • 최적화를 위해서는 다음과 같은 과정을 거치는 것이 좋습니다
    1. 기존 구현 코드의 성능을 측정하고 비교하기 위한 기준치를 얻습니다.
    2. 최적화할 추상화 코드를 확인합니다.
    3. 최적화할 코드를 알고리즘과 자료구조로 분해합니다.
    4. 최적이 아닌 알고리즘과 자료구조를 변경하거나 바꿉니다. 그리고 변경한 코드가 효과적인지 확인하기 위해 실험합니다.
  • 예를 들어, std::string을 키 값으로 가지는 const std::map을 생각해보면 최적화할 여지는 다음과 같습니다.
    • std::map은 메모리 관리자를 자주 호출하여 캐시 지역성이 좋지 않습니다.
    • std::string은 값 변경에는 좋지만 키를 수정할 필요가 없다면 불필요한 변환을 발생시키거나 필요 이상의 기능을 가지고 있습니다.

9.3 std::map을 사용한 검색 최적화

  • 위의 예시에서의 문제점은 다음과 같이 해결 가능합니다.
    • std::map에 std::string 대신에 고정된 크기를 갖는 문자열 키 혹은 C 스타일 문자열 키를 활용합니다. 이를 통해 불필요한 변환을 줄이거나 메모리 할당이 줄어들 수 있습니다.
    • 이때 비교 함수 대신에 비교 함수 객체를 쓰는 것이 더 효과적인데 이는 함수 객체의 연산자()는 인라인화 가능하기 때문입니다. 또한 인수를 받거나, 템플릿에 사용 가능하기 때문에 더 다양한 곳에서 사용될 수 있습니다.
    • 키가 값에 존재할 때는 std::set을 활용하면 됩니다. 

9.4 <algorithm> 헤더를 사용한 검색 최적화

  • 시퀀스 컨테이너는 메모리 및 설정 비용이 저렴합니다. 정적으로 활용한다면 메모리 생성 파괴 비용을 없앨 수 있습니다.
  • std::find()는 O(n)의 시간을 필요로 합니다.
  • std::binary_search는 값의 여부만을 이진 탐색을 통해 찾습니다.
  • std::equal_range는 정렬된 경우 값이 같은 항목을 포함하는 iterator 쌍을 반환합니다. 하지만 내부적으로 std::lower_bound와 std::upper_bound를 사용하기에 느립니다.
  • std::lower_bound는 이진 탐색으로 key보다 작지 않은 테이블의 첫 번째 항목을 가리키는 반복자를 리턴합니다. C++ 표준 라이브러리 알고리즘에서 가장 좋은 선택 중 하나라고 보입니다.
  • 혹은 직접 이진 탐색 검색을 코딩할 수도 있습니다. 이는 std::lower_bound와 큰 성능 차이를 보이지는 않을 것입니다.
  • 문자열의 경우 이진 탐색과 strncmp를 동시에 합친다면 최적화 가능합니다

9.5 해시 키/값 테이블 검색 최적화

  • 해시 테이블의 개념은 키를 해시 함수에 넣어 정수 해시 값으로 변환하는 것입니다. 이때 키의 타입은 상관 없습니다. 해싱은 선형 검색과 마찬가지로 키 사이의 순서 관계를 가정하지 않습니다.
  • 효율적인 해시 함수를 찾을 때 고민해야하는 부분은 해시 충돌인데, 이러한 경우 체인을 쓰거나 다른 곳을 탐색하는 방법이 존재합니다. 이러한 경우 해시 테이블의 성능이 O(n)으로 떨어질 수도 있습니다.
  • 이는 C++에서 std::unordered_map으로 구현될 수 있습니다.

9.6 스테파 노프의 추상화 패널티

  • C++ 표준 알고리즘과 직접 코딩한 알고리즘 간의 성능 차이를 스테파노프의 추상화 패널니라고 하는데, 이는 사용자 정의로 직접 코딩한 해결책과 대조적으로 보편적인 해결책을 제공하는 필연적인 비용입니다.

9.7 C++ 표준 라이브러리로 정렬 최적화

  • 시퀀스 컨테이너에서 검색을 효율적으로 하기 위해서는 정렬되어야만 합니다.
  • std::sort는 퀵 정렬을 변형해서 구현했으며, std::stable_sort는 병합 정렬과 힙 정렬을 변형하여 구현하였는데, 이는 둘다 보통 O(NlogN)의 속도를 가집니다.

Chapter 10. 자료구조 최적화

10.1 표준 라이브러리 컨테이너 알아보기

  • C++ 표준 라이브러리 컨테이너들은 유사해보이지만 서로 달라 대체하기 어렵습니다.
  • 시퀀스 컨테이너는 항목을 삽입한 순서대로 저장하며, 앞과 뒤가 존재합니다. index가 있거나 전방 삽입이 효율적인 시퀀스 컨테이너가 존재합니다.
  • 연관 컨테이너는 항목을 삽입한 순서대로 저장하지 않고 항목의 순서 특성에 기반해 저장합니다. 모든 연관 컨테이너는 효율적으로 항목에 접근 가능합니다.

10.2 std::vector와 std::string

  • std::vector 접근에 있어 [], at, iterator 순으로 속도가 차이납니다([]가 제일 빠름);

10.3 std::deque

  • std::deque는 std::vector의 상위호환처럼 보이지만, 모든 연산이 std::vector보다 성능이 좋지 않습니다.
  • std::deque는 여러 배열을 저장하는 배열로 구현하여, 캐시 지역성을 감소시키고 메모리 관리자를 더 자주 호출하게 합니다.
  • std::deque에 목록을 삽입하면 할당자가 2번 불릴 수 있는데, 하나는 항목을 다른 블록에 추가할 때, 다른 하나는 내부 배열을 확장할 때 쓰일 수 있습니다.

10.4 std::list

  • 연결 리스트를 통해 구현되어 있습니다.
  • 반복하는 유일한 방법은 iterator입니다.
  • 정렬할 때, std::sort를 사용하면 n^2의 성능을 가지며, std::list 자체적인 sort를 사용하는 것이 좋습니다.

10.5 std::forward_list

  • std::list와 달리 순방향 반복자만 제공합니다.

10.6 std::map과 std::multimap

  • 노드 기반의 자료구조로 균형 이진 트리를 통해 키의 값에 따라 노드를 정렬합니다.

10.7 std::set과 std::multiset

  • std::set과 std::multiset은 std::map과 동일한 자료구조를 사용하므로 성능의 특성을 맵과 동일합니다.

10.8 std::unordered_map과 std::unordered_multimap

  • 키 타입의 인스턴스를 해당하는 값 타입의 인스턴스에 매핑하는데 이를 해시 테이블로 구현합니다.
  • 항목의 개수를 해시 테이블의 크기인 버킷으로 나눈 값을 로드 팩터라고 하는데, 로드 팩터가 커지면 효율이 좋지 않기 때문에, 일정 이상을 초과하면 더 크게 재할당합니다.
  • 검색은 빠르지만 저장 공간의 크기가 많이 필요합니다.

Chapter 11. 입출력 최적화

11.1 파일을 읽는 방법

void read_streambuf_stringstream(std::istream f, std::string& result) {
  while(getline(f, line))
    (result += line) += "\n"; 
}

11.2 파일 쓰기

  • std::endl보다는 "\n"을 사용하는 것이 쓰는 속도가 빨라집니다.

11.3 std::cin으로 읽어서 std::cout으로 쓰기

  • std::cin이 std::cout에 묶여 있어, std::cin에서 입력을 요청하면, std::cout을 먼저 비웁니다.

Chapter 12. 동시성 최적화

12.1 동시성

  • 동시성이란 여러 스레드를 동시에 실행하는 성질을 뜻합니다. 자원을 최대한 활용해 전체 실행 시간을 줄이는 것을 목표로 합니다.
  • 시분할은 운영체제에 있는 스케줄러의 기능으로 현재 실행 중인 프로그램과 시스템 작업 목록을 유지하고 프로그램에 사용할 수 있는 시간을 할당합니다. C++은 프로세스가 시분할 환경에서 실행되는 것을 알지 못합니다.
  • C++는 하이퍼바이저로 인한 가상화를 통해 실행되고 있는 것을 인지하지 못하며, 제한된 자원을 갖고 있다는 사실을 간접적으로만 인지 가능합니다.
  • 두 제어 스레드를 동시 실행하는 작동은 두 스레드가 적재 및 저장하는 간단한 작동에 대한 교차 실행으로 모델링 할 수 있으며, 이에 가능한 경우의 수는 많습니다.
  • 오늘 날의 멀티 코어 프로세서는 각 문장의 교차 실행이 가능하기 때문에 경쟁 상태가 자주 발생합니다.
  • 동기화는 여러 스레드에 있는 문장의 교차 실행 순서를 강제하는 것입니다. 보통 뮤텍스와 세마포어와 같은 동기화 장치를 가지고 있습니다.
  • 원자성은 연산의 도중 상태를 살펴볼 수 없는 상태인 경우를 말합니다.

12.2 C++ 동시성 기능

  • std::thread를 통해 호출 가능한 객체를 취하여 스레드 객체에서 실행합니다.
  • std::promise와 std::future은 하나의 스레드에서 다른 스레드로 메세지를 보내고 받습니다. 이는 공유 상태라고 하는 동적으로 할당된 변수를 공유합니다.
  • concurrency in action 2,3,4장 참고하기

12.3 C++ 프로그램 스레드 최적화

  • std::thread보다는 std::async을 사용하면 스레드의 컨텍스트에서 호출 가능한 객체를 실행하지만, 구현에서는 스레드를 재사용할 수 있습니다.
  • 실행 가능한 스레드를 코어 수만큼 많이 만들면, 실행 가능한 스레드는 컴퓨팅 자원을 100% 소비하지만, 더 많이 만든 경우는 대기 가능한 스레드는 외부 이벤트를 기다려야 하기 때문에 더 오랜 시간이 걸립니다.
  • 수명이 긴 자료구조인 스레드 풀과 수행할 계산 목록을 포함하는 자료구조인 태스크 큐를 제공하여 스레딩을 명시적으로 만들어 해결할 수 있습니다.

12.4 더 효율적인 동기화 만들기

  • 임계 구역에서 다른 스레드들은 기다려야 하기 때문에, 임계 구역의 범위를 줄여 빨리 벗어나도록 합니다. 임계 구역에서 I/O를 수행하면 최적의 성능을 이끌어내기 힘듭니다.
  • 앞서 말한 듯 너무 많은 스레드는 오히려 성능을 낮추기에, 실행 가능한 스레드 수는 프로세서의 코어 수보다 작거나 같아야합니다.
  • 놀란 양 떼 현상은 하나의 스레드만 서비스할 수 있는 이벤트에서 많은 스레드가 대기할 때, 이벤트가 발생하면 모든 스레드들이 실행할 수 있지만, 즉시 실행 가능한 수는 코어에 비례하며, 나머지 스레드들은 실행 가능한 큐로 옮겨지게 되면서 대기하는 시간이 길어지게 됩니다. 이는 스레드 수를 줄여 해결가능합니다.
  • 락 전달은 피하는 것이 좋습니다.
  • 메모리와 입출력, 복제 분할 자원을 줄여 경쟁 상태를 줄이는 것이 좋습니다.

Chapter 13. 메모리 관리 최적화

메모리 관리자는 동적 변수의 메모리 할당을 감독하는 C++ 런타임 시스템의 함수 및 자료구조 집합입니다.

13.1 C++ 메모리 관리 API

  • new 표현식은 할당 단계와 배치 단계를 수행 합니다.
  • 사용 단계 후 delete 표현식은 파괴 단계와 해제 단계를 수행합니다.
  • 할당 - 프로그램이 메모리 관리자에겍 연속적인 메모리 영역을 가리키는 포인터를 반환하도록 요청
  • 배치 - 할당된 메모리에 값을 저장하여 생성
  • 사용 - 프로그램에서 메모리와 값을 사용
  • 파괴 - 동적 변수에 대해 클래스의 소멸자를 호출
  • 해제 - 동적 저장 공간을 메모리 관리자에게 반환
  • operator new는 할당을 구현합니다.

'C++공부 > 그 외의 C++' 카테고리의 다른 글

전문가를 위한 C++ : 13 ~ 15장  (0) 2022.07.14
전문가를 위한 C++ : 10 ~ 12장  (0) 2022.07.13
전문가를 위한 C++ : 4 ~ 9장  (0) 2022.07.12
전문가를 위한 C++ : 1 ~ 3장  (0) 2022.07.11
Optimized C++ : 4 ~ 8장  (0) 2022.07.07

Chapter 4. 문자열 최적화

std::string은 C++ 표준 라이브러리에서 많이 사용되는 기능 중 하나로 문자열 조작하는 코드는 자주 실행되며 최적화한다면 좋은 결과를 낼 수 있습니다

4.1 문자열이 왜 문제인가요

  • 문자열에는 구현과 관계없이 메모리를 동적으로 할당하지만 표현식에서는 값처럼 작동하여 복사하기에 비용이 높은 함수가 많습니다.
  • 먼저, 문자열은 메모리를 동적으로 할당합니다.
  • 하지만 문자열은 대입문과 표현식에서 값처럼 작동하여, 임시 문자열의 값도 할당되어야 하기때문에, 메모리 관리자를 많이 호출합니다.
  • 값처럼 작동하지만 복사 비용이 큰 것을 COW(copy-on-write)라고 하는데, 값을 공유하다가 변경할 때만 복사하여 사용하는 것인데 표준 라이브러리에서는 제공하지 않습니다.
  • C++11 표준에서 우측값 참조와 이동 문법이 생기며 복사하기 보다는 이동시켜 비용이 낮게 작업이 수행 가능합니다.

4.2 문자열 최적화 첫번째 시도

  • 먼저 문자열에 무언가를 더 더할 때 operator=를 사용하기 보다는 operator+=를 사용한다면 임시 객체가 사라지기에 효과적이게 됩니다.
  • 만약 문자열의 길이를 사전에 어느 정도 예측이 가능하다면 reserve()로 할당하고 연산을 진행하는 것이 좋습니다.
  • 변경하지 않는다면 인자로 받는 std::string은 const std::string&와 같이 참조자 형태로 받는 것이 좋습니다. 하지만 참조자라는 것은 컴파일러에서 포인터로서 해석이 되고, 이를 역참조한 포인터에 접근하는 것이기 때문에 성능이 저하될 수도 있습니다.
  • 따라서 위와 같은 경우에는 포인터를 역참조하게 하는 것이 아닌, iterator를 소환하여 해결할 수 있습니다.
  • 만약 결과값도 std::string의 형태라면, 지역 변수를 return 하기 보다는 밖에서 참조 형태의 인자를 받아 이에 할당하는 것도 괜찮지만, 잘 못 사용하게 되는 경우도 존재합니다.
  • 성능을 최대한으로 하고 싶다면 C 스타일의 문자열 함수를 사용할 수 있습니다. 이는 여러 함수 호출과 캐시 지역성을 향상하기에 성능이 높아집니다.

4.3 문자열 최적화 두번째 시도

  • 자체적으로 더 나은 알고리즘을 사용하는 것이 가장 좋은 방법으로 작동할 때도 있습니다.
  • 더 좋은 컴파일러를 사용하면 더 좋은 결과를 낼 수도 있습니다.
  • 더 좋은 문자열 라이브러리를 사용하면 더 많은 기능을 가져 선택의 폭도 넓어지고 성능이 좋아질 수 있습니다.
  • std::stringstream은 데이터를 추가할 수 있는 엔티티처럼 크기를 동적으로 바꿀 수 있는 버퍼를 다른 방법으로 캡슐화하여 조금 더 효율적으로 코딩이 가능합니다.
  • std::string_view, folly_fbstring과 같은 다른 다양한 라이브러리로 std::string을 대체하는 것도 하나의 방법입니다.
  • 좀 더 좋은 할당자를 이용한다면, 효율이 올라갑니다.

4.4 문자열 변환 연산 제거하기

  • c 문자열에서 std::string으로의 불필요한 변환 연산을 막는 것이 좋습니다.
  • 리터럴 c 문자열과 UTF-8 문자열을 비교 하는 경우와 같이, 문자 인코딩 사이의 변환을 하나의 형식을 택해 모든 문자열을 선택한 형식으로 바꾸어 저장하는 것으로 막을 수 있습니다.

Chapter 5. 알고리즘 최적화

5.1 알고리즘의 시간 비용

  • 시간 비용은 함수의 입력값에 따라 알고리즘의 비용이 얼마나 증가하는 지를 추상적으로 나타낸 수학 함수입니다.
  • 성능을 고려하는 데에 있어, 순서에 따라 성능이 바뀐다면, 최악을 고려해야합니다.
  • 상환 시간 비용은 입력값이 클 때 전체 시간 비용을 입력값으로 나눈 평균 시간 비용을 의미합니다.

5.2 검색과 정렬을 최적화하는 툴킷

  • 평균적으로 시간이 적은 알고리즘을 선택하고, 데이터의 특징에 따라 더 빠른 특정한 알고리즘을 선택합니다.

5.3 효율적인 검색 알고리즘

  • 선형 검색 - O(n)으로 가장 일반적이며 정렬되지 않은 테이블에서 사용가능합니다.
  • 이진 검색 - O(logn)으로 성능은 뛰어나지만 가장 빠르진 않습니다. 입력 데이터가 키를 기준으로 정렬되어 있어야만 합니다.
  • 보간 검색 - O(loglogn)으로 키의 부가적 정보를 사용하여 키가 균일하게 분포되어 있으면 성능이 매우 뛰어납니다.
  • 해싱 검색 - O(1)으로 키를 해시 테이블의 배열 색인으로 변환하여 빠르게 찾습니다. 최악의 경우는 O(n)으로 검색할 레코드 보다 해시 테이블 항목이 많은 경우도 존재합니다.

5.4 효율적인 정렬 알고리즘

  • 퀵 정렬이 항상 빠르지는 않고 최악에는 O(n^2)의 성능을 보이기에 입력 데이터에 대해서 알고나서 정렬을 선택하는 것이 좋습니다.

5.5 최적화 패턴

  • 사전 계산 - 자주 사용하는 코드를 미리 계산하여 실행 횟수가 많은 부분의 계산량을 줄이는 최적화 기법입니다. 이는 컴파일 타임에 특정 인수를 갖는 템플릿 함수 호출도 계산이 가능합니다.
  • 지연 계산 - 계산 코드를 실제로 필요한 부분과 최대한 가까운 곳으로 미뤄서 계산하는 방법으로, 모든 실행 경로에서 계산할 필요가 없다면 필요한 곳에서만 계산하도록 만듭니다. 예로 COW가 존재합니다.
  • 배칭 - 여러 작업을 함께 처리하는 것으로, 반복된 함수 호출 코드와 한 번에 하나씩 처리할 떄 발생하는 계산 코드를 제거할 수 있습니다.
  • 캐싱 - 필요할 때마다 결과를 다시 계산하지 않고 저장한 뒤 재사용해 계산량을 줄이는 최적화 기법입니다.
  • 특수화 - 어떤 특정 상황에서 필요하지 않은 고비용 계산을 제거하는 방법입니다. std::swap을 특수화하는 것과 같습니다.
  • 더 큰 조각 선택하기 - 반복 과정에서 생기는 오버헤드를 줄이고 반복 횟수를 줄이는 방법입니다. 예를 들어 커널로 보내는 함수의 호출 횟수를 줄이는 것입니다.
  • 힌팅 - 힌트를 사용해 연산 비용과 계산량을 줄이는 최적화 기법입니다.
  • 예상 경로 최적화 - if문을 무작위 순서로 배치했다면, 더 확률이 높은 구문을 앞으로 내보내는 방법입니다.
  • 해싱 - 입력값이 큰 자료구조나 길이가 긴 문자열을 해시로 정수로 변환하여 사용하는 방법입니다.
  • 이중 검사 - 비용이 크지 않은 검사로 몇몇 경우를 배제하고 필요하다면 비용이 큰 후속 검사로 다른 모든 경우를 배제하는 최적화 기법입니다.

Chapter 6. 동적 할당 변수 최적화

메모리 관리자의 호출 횟수를 줄여 최적화하는 방법입니다.

6.1 C++ 변수

  • 모든 C++ 변수는 메모리에 고정된 레이아웃을 가지며, 그 크기는 컴파일 타임에 결정됩니다.
  • 프로그램이 변수의 크기를 바이트 단위로 얻고 변수에 대한 포인터를 선언하는 것은 허용하지만 비트 단위는 불가합니다.
  • 개발자는 구조체에서 변수의 순서와 레이아웃을 추론할 수 있는데 이는 C++의 공용체 타입을 사용 가능토록 합니다.
  • 변수는 저장 기간을 가지며, 정적 변수는 해당 함수에 진입할 때 생성되며 고정 메모리 주소에서 고정 크기를 차지합니다. 정전 변수를 위한 저장 공간 생성에는 런타임 비용이 들지 않습니다.
  • C++11부터 스레드 지역 저장(TLS) 기간을 갖는 변수를 선언할 수 있습니다. 스레드 지역 변수는 스레드에 진입할 때 생성되어 스레드가 끝날 때 파괴됩니다. 스레드 지역 변수는 환경에 따라 다르지만 정적 변수보다 접근 비용이 비쌉니다.
  • 자동 저장 기간을 갖는 변수는 함수 호출 스택에서 컴파일러가 예약해둔 메모리에 상주하며 컴파일 타임에 크기가 결정되어 각 함수 호출 스택 포인터에서 고정된 오프셋 위치를 가지게 됩니다.
  • 동적 저장 기간을 갖는 변수는 실행 중인 프로그램에서 요청한 메모리에 상주하며, 메모리 풀을 관리하는 자료구조로 구성된 메모리 관리자를 호출하여 프로그램이 저장 공간을 명시적으로 요청하여 변수를 동적 생성하며, 명시적으로 파괴할 수 있습니다.
  • 값을 통해 의미를 갖는 변수는 값 객체, 프로그램에서의 역할에 따라 의미를 얻는 변수는 엔티티라고 합니다.
  • 엔티티는 유일하며, 변경될 수는 있지만 복사될 수는 없고 비교할 수도 없습니다. ex) mutex, symbol table 
  • 값은 교환, 비교 및 복사는 가능하지만, 변경할 수는 없습니다.

6.2 C++ 동적 변수 API

  • 스마트 포인터는 동적 변수의 소유권을 자동화합니다.
  • 동적 변수의 소유권을 shared_ptr로 공유하면 비용이 더 커집니다.
  • 동적 변수는 런타임 비용이 존재합니다.

6.3 동적 변수 사용 줄이기

  • 클래스 인스턴스를 정적으로 만드는 것이 좋습니다.
  • 클래스 멤버 변수를 정적으로 만드는 것이 좋습니다.
  • std::vector이나 std::string 대신 std::array와 같은 정적 자료구조를 사용하는 것도 한 방법입니다.
  • 또는 스택에 큰 버퍼를 만들거나, 연결 자료구조를 정적으로 만드는 방법도 존재합니다.
  • new 대신 std::make_shared를 사용하면 할당을 한번에 할 수 있습니다.
  • 소유권을 불필요하게 공유하지 않고 포인터를 내보내는 것이 좋습니다.
  • 동적 변수를 소유하기 위한 스마트 포인터를 사용하는 것이 좋습니다.

6.4 동적 변수의 재할당 줄이기

  • reserve와 같이 동적 변수를 미리 할당해 재할당을 방지하는 것이 좋습니다.
  • 반복문 바깥에서 동적 변수를 만드는 것이 좋습니다.

6.5 불필요한 복사 제거하기

  • 클래스 정의에서 원치 않는 복사 방지시켜야 합니다.
  • 함수 호출에서 복사를 제거할 수도 있습니다.
  • 함수 반환에서 반환값 최적화(RVO) 혹은 출력용 매개변수를 사용하여 복사 제거할 수 있습니다.
  • 참조를 사용하여 라이브러리를 왔다갔다 거리는 복사 없는 라이브러리를 활용하는 방법도 존재합니다.
  • COW를 구현하여 평소에는 얕은 복사를 수행하다 필요할 때만 깊은 복사를 수행토록 합니다. 

6.6 이동 문법 구현하기

  • 우측값을 이용하여 이동 문법을 구현하도록 합니다.
  • 복사 생성자와 메모리 관리 함수에 시간 낭비가 많다면 이는 이동 문법을 통해 효율이 좋아집니다.
  • 우측 값으로 반환하려고하면 RVO와 충돌하여 오히려 효율이 떨어지게 됩니다.

6.7 평평한 자료구조

  • 자료구조의 요소들이 인접한 저장 공간에 함께 저장되었다면 자료구조가 평평하다고 합니다.
  • 평평한 자료구조는 포인터로 연결된 자료구조 보다 메모리 관리자 호출이 적습니다.
  • 노드 기반 자료구조에 비해 평평한 자료구조는 메모리를 적게 차지합니다.

Chapter 7. 문장 최적화

  • 문장 수준에서 이뤄지는 최적화는 실행할 때 불필요한 명령어를 제거하는 과정으로 비유됩니다.
  • 문장에서 이뤄지는 최적화의 문제점은 컴파일러에 따라 최적화의 효율성이 달라질 수 있다는 점입니다.

7.1 반복문에서 코드 제거하기

  • 반복문의 종료값을 캐싱하십시오.
  • 값을 증가하는 대신 감소하게 하여, 종료 조건에서의 비용을 줄이는게 좋습니다.
  • 반복문에서 불변의 값들은 밖으로 옮기는 것이 좋습니다.
  • 반복문에서 불필요한 함수 호출을 제거하는 것이 좋습니다.
  • 반복문에 숨겨진 함수 호출을 제거하는게 좋습니다. 이는 클래스 타입 변수에서 많이 발생합니다.
  • 반복문에서 비용이 크고 변화가 느린 호출을 제거하는게 좋습니다.
  • 반복문을 함수 안에 넣어 호출 오버헤드를 줄이는게 좋습니다.
  • 특정 행동을 하는 횟수를 줄이는 것이 좋습니다.

7.2 함수에서 코드 제거하기

  • 함수 호출은 다음과 같이 이뤄집니다.
    1. 함수의 인수와 지역 변수를 저장하기 위해 호출 스택에 새 프레임을 삽입
    2. 각 인수 표현식을 계산하여 스택 프레임에 복사
    3. 실행 주소를 복사하여 스택 프레임에 반환 주소로 대입
    4. 실행 코드는 실행 주소를 함수 본문의 첫번째 문장으로 갱신
    5. 함수 본문의 명령어 실행
    6. 스택 프레임에 저장되어 있는 반환 주소를 명령어 주소에 복사
    7. 호출 스택에서 스택 프레임을 삭제
  • 가상 함수의 경우는 vtable을 통해 호출되기에 비순차적 메모리를 두번 불러와야해서, 캐시 미스와 실행 파이프라인 스톨의 가능성이 높아지며, 인라인화하기 어려워집니다.
  • 간단한 함수는 인라인으로 선언하여 함수 호출 비용을 줄입니다
  • 함수를 처음 사용하기 전에 정의를 하여 사용합니다.
  • 사용하지 않는 다형성은 제거하는 것이 좋습니다.
  • 사용하지 않는 인터페이스는 버려 인스턴스를 생성하지 않도록 합니다.
  • 템플릿으로 컴파일 타임에 구현을 선택하는 것이 좋습니다.
  • PIMPL 관용구를 사용하는 코드를 제거하는 것이 좋습니다. 이는 예전에는 유의미한 컴파일 타임 감소를 가져왔지만, 현재는 컴파일이 빠르기에 사용하지 않아도 괜찮습니다.
  • DLL을 호출하는 코드를 제거하고, 라이브러리를 하나의 실행 파일로 만드는 방법으로 성능을 향상시킬 수 있습니다.
  • 멤버 함수 대신 정적 멤버 함수를 사용하면, 비용이 큰 멤버 함수 포인터 대신 일반 함수 포인터를 참조할 수 있습니다.
  • 가상 소멸자를 기본 클래스로 옮기는 것으로 vtable 포인터가 기본 클래스에 포함되게 하는 것이 좋습니다.

7.3 표현식 최적화

  • 표현식을 단순하게 만드는 것이 좋습니다.
  • 상수끼리 계산은 컴파일 타임에 이루어지기에 상수는 모아서 표현하는 것이 좋습니다.
  • 비용이 적은 연산자를 사용하는 것이 좋습니다.(비트 연산)
  • 부동 소수점 연산 대신 정수 연산이 빠르므로 사용하는 것이 좋습니다.
  • double이 float보다 빠를 수 있습니다.
  • 반복 계산을 닫힌 형태로 바꾸는 것이 좋습니다.

7.4 제어 흐름 최적화

  • if-elseif-else 대신 switch를 사용하는 것이 O(1)로 접근할 수 있습니다.
  • switch나 if 대신 가상 함수를 사용하세요
  • 비용이 들지 않는 예외 처리를 사용하세요

Chapter 8. 라이브러리 최적화

8.1 표준 라이브러리 최적화

  • C++은 범용성을 갖추고자 간결한 표준 라이브러리를 제공합니다. 이는 대부분은 매우 효율적인 코드를 생성하는 템플릿 클래스와 함수로 구성되어 있습니다.
  • 이는 여러 운영체제에서 매우 광범위하게 재사용할 수 있습니다.
  • 표준 라이브러리의 구현이 C++ 표준을 준수하지 않을 수 있습니다.
  • 표준 라이브러리의 개발자에게 가장 중요한 것은 성능이 아니라 범용성입니다.
  • 라이브러리의 구현이 최적화 시도를 좌절시킬 수도 있습니다.
  • C++ 표준 라이브러리의 모든 부분이 똑같이 유용하지는 않습니다. ex) vector<bool>
  • 표준 라이브러리는 운영체제의 가장 좋은 네이티브 함수만큼 효율적이지 않습니다.

8.2 기존 라이브러리 최적화

  • 가능한 한 적게 수정하는 것이 좋습니다.
  • 기능을 변경하기 보다는 추가하는 것이 좋습니다.

8.3 최적화된 라이브러리 설계

  • 급하게 설계한 라이브러리나 라이브러리 안에 독립적으로 작성된 함수 집합은 호출 및 규칙, 작동 및 효율성이 일치하지 않을 수도 있고, 라이브러리의 범용성을 해칠 수도 있습니다.
  • 자원을 절약하도록 짜는 것도 중요합니다.
  • 라이브러리 바깥에서 메모리 할당을 결정하는 것이 좋습니다.
  • 확신이 서지 않는다면 속도를 위한 라이브러리 코드를 작성하는 것이 좋습니다.
  • 함수가 프레임워크보다 최적화하기 쉽습니다.
  • 상속 계층 구조를 평평하게 만드는 것이 좋고, 이는 대부분 3계층 이하인 것이 좋습니다.
  • 호출 체인을 평평하게 만들어, 함수 호출의 중첩 횟수가 3회 이하로 만드는 것이 좋습니다.
  • 계층화된 설계를 평평하게 만드는 것이 좋습니다.
  • 동적 검색을 피하는 것이 좋습니다.

'C++공부 > 그 외의 C++' 카테고리의 다른 글

전문가를 위한 C++ : 13 ~ 15장  (0) 2022.07.14
전문가를 위한 C++ : 10 ~ 12장  (0) 2022.07.13
전문가를 위한 C++ : 4 ~ 9장  (0) 2022.07.12
전문가를 위한 C++ : 1 ~ 3장  (0) 2022.07.11
Optimized C++ 9 - 13장  (0) 2022.07.08

+ Recent posts