센로그

4. Object-oriented programming (2) 본문

게임/게임 엔진 기초

4. Object-oriented programming (2)

seeyoun 2022. 12. 7. 15:38

◆ C++ 처리 과정

전처리 - 컴파일 - 링크

 


▶ Preprocessing (전처리)

.cpp + .h
컴파일러가 코드를 이해하기 쉽게 처리해줌

 

헤더파일 합쳐주고, #pragma once 처리하고,... 그런 거 함

 

전처리의 네단계 과정

  • Trigraph replacement (트라이그래프 대체)
    • 과거 특수문자 사용이 어려울 때 트라이그래프(3개의 문자 조합)를 통해 특수문자를 표현했었음
    • 전처리 과정에서 이를 실제 특수 문자로 바꿔줌 
      현대에는 거의 사용되지 않으나, C++ 표준으로 남아있으므로 이런게 사용될수도 있구나 정도만 알고있으면 됨
  • Line splicing (줄 이어붙이기)
    • 역슬래시(\)를 사용해 물리적으로 줄을 나눈 경우 이들을 하나의 논리적 줄로 합쳐줌
  • Tokenization (토큰화)
    • 코드를 토큰과 공백으로 나눔.
    • 주석은 공백으로 대체됨.
  • Macro expansion and directive handling (매크로 확장 및 지시어 처리)
    • #include, #define, #ifdef 등 전처리 지시어가 처리됨
    • #pragma 같은 특수 지시어도 처리됨
      • #pragma once: 헤더 파일 중복 포함 방지
      • #pragma comment: 링커 옵션 설정

 

 


▶ Compilation (컴파일)

.cpp → 어셈블리 코드 
  • Semantic analysis 
    • 문법 오류 분석 (구문 분석, 의미 분석)
    • Syntex error (구문 오류): 문법 틀린 것 (세미콜론 누락 등)
    • Semantic error (의미 오류): 문법적으로는 맞지만, 이해할 수 없는 의미를 가진 것 ( a + b = c 연산 결과를 좌변에 할당)
  • Code generation
    분석한 코드를 어셈블리 코드(== Object Code)로 바꿔줌


▶ Assemble (어셈블)

어셈블리 코드 → Object Code
  • 컴파일러가 준 어셈블리 코드를 Object Code로 변환하여 넘겨줌
  • 메모리 주소에 대한 직접 접근이 일어남

 


▶ Linking (링킹)

실행가능한 파일로 만들어 줌

 

여러 개의 Object 파일을 합쳐서(링크) 하나의 실행가능한 파일로 만듦

(기계어로 된)라이브러리 연결해주기도 함!

 


◆ Auto

알아서 어떤 형인지 추측 (타입 유추)

 

C++11부터 도입된 문법.

 

장점

  • 타입이 바뀌어도 코드가 그대로 동작함
  • 타입 캐스트가 발생하지 않으므로, 타입캐스트로 인한 성능 저하가 없음
    • return type이 여러 종류인 형태로 overload 되어있는 함수의 반환값을 받는 경우에도 알아서 받아올 수 있음
  • 긴 타입 이름을 직접 안써도 되니 오타 걱정이 없음
  • 간결하게 코드를 읽을 수 있게 됨

단점

  • 남용한다면 오히려 가독성이 떨어질 수 있음.
    → C++에서는 주로 내부에서 끝나는 고립계에 많이 씀! for(auto i : list) 이런 거

 

 


◆ nullptr

#define NULL_PTR 0L

 

얘도 C++ 11부터 도입.

 

초기 C++에서는 nullptr대신에 NULL 키워드나 0을 사용했음.

  • NULL 은 단순히 0으로 정의되어 있는 것임.
  • NULL이나 0으로 포인터를 표현한다면, 정수와 포인터 사이에 혼동이 발생할 수 있음.
    • 예를들어 NULL이 정수이기 때문에, 포인터가 아닌 정수 인수로 오버로드 된 함수가 호출되는 경우가 있음.

 

그래서 이젠 명시적으로 nullptr 사용

  • nullptr은 포인터 타입과만 호환됨 (std::nullptr_t 타입으로 정의)

 


◆ Range-based for Loops 

시작과 끝 몰라도 전체 사이즈만큼 다 반복하기 ㄱㄴ

 

C++11부터 auto 키워드가 도입되면서, 이렇게 foreach 문 같은 형태가 사용 가능하게 되었음 

std::vector<int> v = { 1, 2, 3, 4, 5};
for(auto i : v)
{
	std::cout<<i<<' ';
}

 


◆ Iterator

STL 컨테이너에서, 순회를 추상화하기 위한 객체.

 

std::vector<int> v = { 1, 2, 3, 4, 5};
for(std::vector<int>::iterator iter = v.begin(); iter != v.end(); ++iter)
{
	std::cout<<*iter<<' ';
}​

 

 

  • 반복자STL 컨테이너의 요소에 효율적으로 접근하고 안전하게 순회할 수 있는 방법을 제공함
    • 컨테이너 추상화
      • 모든 컨테이너에 대해 일관된 순회 방식 제공
    • 범위 초과 방지
    • 효율적인 데이터 처리
      • 링크드 리스트나 트리 구조에서 효율적으로 순회할 수 있도록 함
  • 다만 무조건 메모리 주소를 가리키는 건 아님
    • *iter를 사용하는 방식 때문에 이터레이터가 포인터처럼 메모리 주소를 가리킨다고 생각할 수 있지만, 이터레이터 객체(iter) 자체가 곧 메모리 주소를 직접 저장하고 있는 것은 아닙니다. 이터레이터는 포인터처럼 보이지만, 추상화된 객체일 뿐입니다. STL에서 이터레이터는 연산자 오버로딩을 통해 포인터처럼 동작할 뿐이며, 실제로 메모리 주소를 직접적으로 저장하는지는 컨테이너에 따라 다릅니다.
      • *iter를 통해 요소에 접근할 수 있는 것은 연산자 오버로딩 덕분입니다. 이터레이터 클래스는 operator*를 오버로딩하여, 포인터의 * 연산자처럼 동작하도록 구현되어 있습니다.
        • 실제로 iter가 저장하고 있는 것은 해당 요소의 메모리 주소일 수도 있지만, 꼭 그렇지는 않습니다. 예를 들어, std::list 같은 연결 리스트 컨테이너에서는 각 요소가 불연속적으로 메모리에 저장되므로, 이터레이터는 특정 메모리 주소를 가리키는 것이 아니라 연결 리스트의 노드 객체에 대한 참조를 유지하는 방식으로 동작합니다.
      컨테이너에 따른 이터레이터 구현 방식
      • vector와 같이 연속된 메모리 블록을 사용하는 컨테이너에서는, 이터레이터가 내부적으로 메모리 주소를 보유할 가능성이 큽니다. 따라서 이 경우, 이터레이터가 포인터와 매우 유사하게 동작합니다.
      • list와 같은 비연속 메모리 구조를 사용하는 컨테이너에서는, 이터레이터가 요소의 메모리 주소를 직접 가리키는 대신, 노드 참조 등을 통해 포인터와 유사하게 구현됩니다.
  • 복잡성을 줄이고 프로그램의 실행 시간을 최적화

 

 

※이터레이터 vs 포인터

더보기

차이점 및 유사점

  1. 유사점:
    • 이터레이터는 포인터처럼 컨테이너의 요소를 가리키고, * 연산자를 사용하여 요소에 접근하거나 ++ 연산자를 사용하여 다음 요소로 이동할 수 있습니다.
    • 포인터와 비슷한 방식으로 컨테이너 요소를 순회하는 기능을 제공합니다.
  2. 차이점:
    • 포인터는 단순히 메모리 주소를 저장하는 변수입니다. 기본적으로 원시 데이터 타입이나 배열에 사용됩니다.
    • 이터레이터 STL 컨테이너에 특화된 객체로, 컨테이너의 구현에 따라 동작 방식이 다를 수 있습니다. 예를 들어, vector의 이터레이터는 연속적인 메모리를 순회하지만, list의 이터레이터는 연결 리스트를 순회하는 방식으로 구현됩니다.
    • 이터레이터는 추상화된 인터페이스를 제공하여, 컨테이너의 내부 구조를 알 필요 없이 요소를 순회할 수 있게 해줍니다.

 

#include <iostream>
#include <vector>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};
    std::vector<int>::iterator it;

    for (it = numbers.begin(); it != numbers.end(); ++it) {
        std::cout << *it << " ";  // 이터레이터로 벡터 요소에 접근
    }

    return 0;
}

 

 


◆ virtual, final, override

상속, 다형성을 위해 사용되는 개념
코드 읽는 사람 뿐만 아니라 컴파일러에게 내 의도를 전달할때도 씀

 

얘네도 C++11에서 추가된 키워드임.

class B
{
    virtual void func0();	//가상함수
    virtual void func1();	//가상함수
    void func2() final;		//더이상 오버라이드 하지 마!
};

class D1 : B
{
    void func0() override;		//가능
    void func1() final;			//더이상 오버라이드 하지 마!
    void func2() override;		//B에서 final이었어서, 더이상 오버라이드 못 함
}

class D2 : D1
{
    void func0() override;   //가능
    void func1() override;   //불가능
}

 

 

virtual void func() = 0; 과 같이 선언하는 경우, 순수 가상함수임.

  • 순수 가상 함수는 기본 클래스에서 구현이 없으며, 파생 클래스에서 반드시 재정의해야 함
  • 순수 가상함수를 가지는 클래스는 추상 클래스(abstract class)가 되며, 객체를 생성할 수도 없음

 

일반 가상함수는 기본 클래스에서 구현이 있을 수도 있고 없을 수도 있음.

파생클래스에서도 재정의할 수도 있고 안할 수도 있음

 


◆ Enum Class

열거형. 알아보기 쉬우라고 string이랑 int랑 mapping

 

얘도 C++11에서 도입된 거

 

기존 enum의 한계

  • 범위 제한 없음
    • 전역 범위에 정의됨
    • 따라서 이름 충돌의 위험이 있음
  • 타입 안정성 부족
    • 암시적으로 정수로 변환될 수 있어서, 의도치 않은 타입캐스트가 발생할 위험

 

enum 클래스

 

  • 이름 충돌을 방지
  • 열거형 값이 정수로 암시적으로 변환되지 않음

 

 

기존 enum

enum Color { Red, Green, Blue };  // 전역 범위에 정의됨
enum TrafficLight { Red, Yellow, Green };  // 이름 충돌 위험

int main() {
    Color color = Red;  // Red는 Color인지 TrafficLight인지 모호할 수 있음
    int value = Green;  // Green이 정수로 암시적 변환됨 (타입 안정성 부족)
    return 0;
}

 

 

enum 클래스

enum class Color { Red, Green, Blue };  // 범위가 제한됨
enum class TrafficLight { Red, Yellow, Green };  // 이름 충돌 없음

int main() {
    Color color = Color::Red;  // Color::Red로 명시적 접근
    TrafficLight light = TrafficLight::Green;  // TrafficLight::Green로 명시적 접근

    // int value = Color::Green;  // 오류: 정수로 암시적 변환 불가 (타입 안정성 보장)
    return 0;
}

 


◆ Smart Pointer

포인터 사용 시 메모리 관리를 더 쉽고 안전하게 하기 위한 방법들

 

직접 해제할 필요 없이 자동으로 메모리를 관리하는 포인터

 

일반 포인터 + 메모리 관리 책임을 포함하는 거임 (소유권의 개념)

 

C++11부터 표준화된 스마트 포인터 세 개가 도입되었음.

  • Shared Pointer
    • std::shared_ptr
  • Weak Pointer
    • std::weak_ptr
  • Unique Pointer
    • std::unique_ptr

 

장점

  • null 포인터 역참조 방지 
    • null 포인터가 가리키는 메모리 위치에 접근하려고 시도하는 것 (*nullptr)
  • memory leak 방지
    • 소멸 시 자동으로 메모리를 해제함.
    • 수동으로 delete 호출할 필요 없음.

 

문제점

  • 상속 및 다형성 문제
    • 스마트 포인터는 일반 포인터처럼 상속을 쉽게 다루지 못함.
    • 스마트포인터는 기본 클래스와 파생 클래스 간의 변환이 자동으로 지원되지 않음
      • 템플릿 기반이므로, 정확한 타입 체크를 요구하기 때문.
    • std::static_pointer_cast나 std::dynamic_pointer_cast 등 명시적 타입 변환을 해줘야 함
    • 따라서 일반 포인터를 사용할 때에 비해 복잡하거나 불편할 수 있음

 


▶ Unique Pointer

나만 얘를 소유할 수 있어! (즉, 나만 얘를 지울 수 있어.)

 

 

  • 작동 방식: std::unique_ptr은 소유권이 독점적인 스마트 포인터입니다.
  • 특징: 복사할 수 없고, 소유권을 다른 std::unique_ptr로 이동(std::move())할 수만 있습니다.
  • 사용 사례: 객체가 유일하게 하나의 포인터에 의해 관리되어야 하고, 다른 포인터와 공유되지 않는 경우 사용합니다.
    • 매니저 같은 유일한 클래스 만들 때 사용

 

 

만약 한 번 해제한 메모리를 다시 접근하려고 하면, 시스템 죽을것임 (double free bug)

→ 어떤 포인터에다가 객체에 대한 유일한 소유권을 부여해서,

이 포인터 외에는 객체를 소멸 못 해! 라고 하면 double free bug 발생하지 않을 것임.

//unique ptr 생성.
std::unique_ptr<A> pa(new A());

→ pa 해제하면 new A()도 해제됨

 

 

unique ptr은 소유권 이전은 할 수 있는데, 복사는 안됨! (당연함 유니크해야 되니까)

소유권 이전하려면 std::move 쓰면 됨.

//소유권 이전. 
std::unique_ptr<A> pb = std::move(pa);

 이제 생성된 new A()를 가리키는 건 pb가 됨.
pa와의 연결은 끊긴 거임. (널포인터가 됨)

 

 

참고로, 스마트 포인터가 아닌 일반 포인터로 가리키는 건 가능은 함.

유니크포인터.get()을 통해 객체에 접근할 수는 있음.

그러나 메모리 관리에는 관여 못 함. 

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> uptr = std::make_unique<int>(42);

    // get()을 사용하여 메모리 주소를 가져옴
    int* rawPtr = uptr.get();
    std::cout << "Value: " << *rawPtr << std::endl;  // 출력: 42

    // rawPtr을 통해 객체에 접근할 수 있지만, 메모리 해제는 하지 않음
    // delete rawPtr;  // 이렇게 하면 안 됨! (메모리는 여전히 uptr이 관리)

    return 0;
}

스마트 포인터랑 일반 포인터 같이 쓰지는 말자.

 


Shared Pointer

나 포함, 몇 명이 얘를 소유하고 있는가?
0명이면 해제

 

 

  • 작동 방식: std::shared_ptr은 참조 카운팅(reference counting)을 사용하여 객체의 소유권을 관리합니다.
    • use_count()
  • 참조 카운트: 몇 개의 std::shared_ptr이 동일한 객체를 가리키는지 추적하며, 참조 카운트가 0이 되면 객체가 자동으로 삭제됩니다.
  • 사용 사례: 여러 포인터가 같은 객체를 공유하고, 객체가 마지막 포인터가 삭제될 때 자동으로 해제되길 원할 때 사용합니다.

 

 

여러 명이 객체를 소유하고 있는거임.

그렇기 때문에, Unique때처럼 포인터 delete할 때마다 바로 객체를 지우지 않고, 소유하는 애들이 0명이면 그때서야 해제함

shared_ptr<int> ptr01(new int(5)); // int형 shared_ptr인 ptr01을 선언하고 초기화함.
cout << ptr01.use_count() << endl; // 1
auto ptr02(ptr01);                 // 복사 생성자를 이용한 초기화
cout << ptr01.use_count() << endl; // 2
auto ptr03 = ptr01;                // 대입을 통한 초기화
cout << ptr01.use_count() << endl; // 3

 

 

<주의할 점!>

한 객체를 소유하는 다른 포인터를 만들려면, 복사 생성자 또는 대입을 통해 초기화해줘야 함

shared ptr의 인자로 주솟값이 전달되면, 내가 걜 첫번째로 소유하는 걸로 생각하게 됨.

즉, 참조 카운팅 처음부터 시작한다는 거임.

 

예를들어,

A* a = new A();
std::shared_ptr<A> pa1(a);
std::shared_ptr<A> pa2(a);

이렇게 하면, pa1의 use_count와 pa2의 use_count가 따로 카운팅 된다는 소리.

근데 사실은 한 객체를 가리키고 있자나?

그래서, a 가 두번 소멸됨 (double free bug)

 

pa1과 pa2가 차례로 소멸된다고 치면,

pa1이 소멸될 때 count가 0이 되니까 a가 첫번째로 소멸함

그러나 pa2가 소멸될 때도 다시 count가 0이 되니까, 이미 지워진 a를 또 지우려 함

=> 오류 발생!

 

참고)

유니크 포인터 때와 같이, 일반 포인터로 가리킬 수는 있음.

그러나 당연히 참조 카운팅이 증가하지는 않음.

 

참고)

Shared Pointer도 std::move 사용할 수 있다고 함

 

참고)

make_shared와 new 비교

 


▶ Weak Pointer

Shared pointer 순환 참조 문제를 해결을 위해 만듦

 

 

  • 작동 방식: std::weak_ptr은 std::shared_ptr과 함께 사용되며, 객체의 소유권을 갖지 않지만, 참조할 수 있는 포인터입니다.
  • 목적: 순환 참조(cyclic reference) 문제를 해결합니다. std::shared_ptr 간의 상호 참조로 인해 참조 카운트가 0이 되지 않는 경우, 메모리 누수가 발생할 수 있습니다. std::weak_ptr은 참조 카운트를 증가시키지 않으므로, 이러한 문제를 방지합니다.
  • 사용 사례: 객체를 참조하면서도 소유권을 공유하지 않고, 객체의 생명 주기를 감시하거나, 객체가 해제되었는지 여부를 확인하려고 할 때 사용

 

 

순환 참조란, Shared Pointer가 서로 참조하고 있는 경우를 의미함.

Shared Pointer 순환 참조시 영원히 안 지워지게 되는 문제 해결을 위해 Weak Pointer 도입~

 

객체가 지워졌는지 아닌지 확인하는 방법:

weakpointer.lock()

  • lock() 메서드는 객체가 아직 존재할 경우, 해당 객체를 가리키는 std::shared_ptr를 반환합니다.
  • 만약 std::weak_ptr이 참조하는 객체가 이미 소멸되었다면, lock()은 비어 있는 std::shared_ptr(즉, nullptr을 가리키는 std::shared_ptr)을 반환합니다.
  • 이를 통해, 객체가 소멸되었는지 확인하면서 안전하게 객체에 접근할 수 있습니다.
#include <iostream>
#include <memory>

struct A {
    ~A() { std::cout << "A destroyed" << std::endl; }
};

int main() {
    std::shared_ptr<A> sptr = std::make_shared<A>();
    std::weak_ptr<A> wptr = sptr;  // A 객체를 감시하는 weak_ptr 생성

    sptr.reset();  // A 객체가 소멸됨

    if (wptr.lock()) {
        std::cout << "A object is still alive" << std::endl;
    } else {
        std::cout << "A object has been destroyed" << std::endl;  // 출력
    }

    return 0;
}

 

 

문제상황 예시

#include <iostream>
#include <memory>

struct B;

struct A {
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed" << std::endl; }
};

struct B {
    std::shared_ptr<A> a_ptr;
    ~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b_ptr = b;
    b->a_ptr = a;  // 순환 참조 발생: A와 B가 서로를 가리키고 있음

    return 0;
}
// A와 B가 서로를 가리키고 있기 때문에, 참조 카운트가 0이 되지 않아 메모리 누수 발생
  • A 객체의 참조카운트 2
  • B 객체의 참조카운트 2

끝까지 서로 참조카운트 2인 상태로 남아있음. 순환참조.

 

해결방안 예시

#include <iostream>
#include <memory>

struct B;

struct A {
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed" << std::endl; }
};

struct B {
    std::weak_ptr<A> a_ptr;  // weak_ptr로 순환 참조 방지
    ~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b_ptr = b;
    b->a_ptr = a;  // a_ptr은 weak_ptr이므로 참조 카운트 증가 안 됨

    return 0;
}
// A와 B가 정상적으로 소멸됨
  • A 객체의 참조카운트 1
  • B 객체의 참조카운트 2

 

  • a가 스코프를 벗어나면서 A 객체가 소멸되고, B 객체의 참조 카운트가 감소함
  • b가 스코프를 벗어나면서 B 객체가 소멸됨
  • 결과적으로, A와 B 객체 모두 정상적으로 해제

 

 

 

근데 궁금증이 생길 수 있음.

소유권 개념 없이 참조만 할거면, 그냥 일반 포인터로 참조하면 되지않나?

 

std::weak_ptr vs. 일반 포인터

  • 일반 포인터: 객체의 소멸 여부를 추적할 수 없고, 이미 해제된 메모리를 참조할 위험이 있습니다.
  • std::weak_ptr: 객체의 소멸 여부를 안전하게 확인할 수 있으며, 순환 참조 문제를 방지할 수 있습니다.
    • 참조 카운트에는 영향을 주지 않지만, weak 참조 카운트를 별도로 관리하며, shared ptr과 제어블록을 공유함.
    • 이 제어 블록에 참조 카운트 및 weak 참조 카운트가 있음.
    • 참조 카운트가 0이 되어 객체가 삭제되더라도, 제어 블록은 여전히 남아서 weak ptr에게 이 객체가 해제되었음을 알려줄 수 있음.

 


◆ Lambda Function

이름이 없는 함수 객체.
함수를 밖에다 정의하고 사용하는게아니라, 필요한 부분에 바로 구현해버린 것

 

익명 함수 객체인라인으로 정의할 수 있는 편리한 방법

 


▶ Lamda Function - 왜 이런걸 쓸까?

특정 위치에서 한번만 사용되는 간단한 함수 정의에 유용함.

 

- 코드가 짧고 간결함

- 코드 내에 함수가 있으니까, 남이 볼 때 가독성 향상

- 콜백함수 정의할 때 유용함.

 


▶ Lamda Function - 구조

 

1) 리턴타입 O

[capture list] (파라미터) -> 리턴 타입 { 함수 본체 }

 

2) 리턴타입 X (생략)

[capture list] (파라미터) {함수 본체}

 

capture list에서는, 레퍼런스로 받을건지/copy로 받을건지 이런 거 결정 가능.

 

  • 값으로 캡처 [x]: 변수 x의 복사본을 람다에 전달합니다. 람다 내부에서 x를 수정해도 외부의 x에는 영향이 없습니다.
  • 참조로 캡처 [&x]: 변수 x를 참조로 캡처합니다. 람다 내부에서 x를 수정하면 외부의 x 값도 변경됩니다.
  • 전체 캡처 [=]: 모든 외부 변수를 값으로 캡처합니다.
  • 전체 캡처 [&]: 모든 외부 변수를 참조로 캡처합니다.

 

 

궁금증) 캡쳐로 전달하는거랑 파라미터로 전달하는 거랑 뭐가 다를까?

쉽게 말하면, 값 캡쳐는 해당 시점의 값을 기억하고 싶을 때 사용.

람다 함수를 정의하고나서 바로 사용하지 않을 수 있다는 걸 인지해야 함.

이후에 콜백으로 호출되거나, 비동기 코드면 호출되기 전에 값이 바뀔 수 있는데, 그래도 이미 캡쳐한 값은 안바뀜

#include <iostream>

int main() {
    int x = 10;
    int y = 20;

    // 1. 캡처를 사용한 람다
    auto captureLambda = [x, &y]() {
        std::cout << "캡처된 x: " << x << std::endl;  // x는 값으로 캡처되었으므로, 변경되지 않음
        std::cout << "참조 캡처된 y: " << y << std::endl;  // y는 참조로 캡처되었으므로, 변경될 수 있음
    };

    // 2. 파라미터 전달을 사용한 람다
    auto parameterLambda = [](int a, int& b) {
        std::cout << "파라미터로 전달된 a: " << a << std::endl;  // a는 값으로 전달
        std::cout << "파라미터로 참조 전달된 b: " << b << std::endl;  // b는 참조로 전달
    };

    // 외부 변수의 값 변경
    x = 30;
    y = 40;

    std::cout << "캡처 람다 실행:" << std::endl;
    captureLambda();  // 출력: x: 10, y: 40

    std::cout << "파라미터 람다 실행:" << std::endl;
    parameterLambda(x, y);  // 출력: a: 30, b: 40

    return 0;
}

 

 


▶ Lamda Function - 구현 예시

 

#1

//bool 타입 변수 초기화
bool v1 = [](int i) -> bool { return i % 2 == 1; }
bool v2 = [](int i){ return i % 2 == 1; }

//람다 함수 정의
function<int(bool)> f1 = [](int i) -> bool { return i % 2 == 1; }
function<int(bool)> f2 = [](int i){ return i % 2 == 1; }

 

 

#2

x와 y를 인자로 받아서, x+y를 return 해주는 함수가 f1임

람다 함수를 function object에 바로 구현할 수도 있음.

 


◆ Rvalue Reference, Move semantic

copy할 때 비효율적인 측면을 해결하기 위한 방안

 

얘네도 C++11에서 도입됨

 

C++에서 Copy시 발생하는 비효율에 대한 이야기

벡터의 각 원소에 5배를 곱해서 리턴해주는 함수 MultiplyFive(std::vector<int>& input)를 가정하자

v = MultiplyFive(v); 를 하면 비효율이 발생함.

 

return output을 하면 해당 값이 임시 객체로 생성되며, 호출된 위치에서 v로 복사될 때 불필요한 데이터 복사(deep copy)가 발생함

(return output 할 때만 잠깐 존재하는 임시 객체가 생성되었다가 거기서 다시 복사되는 것임)

 

비효율을 막기 위한 방법 1.

  • 함수 파라미터로 output 자리를 만들어놓고, 레퍼런스로 받아서 초기화하며 사용
  • 다만, 함수를 뜯어고쳐야 하고, 항상 output을 파라미터로 넘겨받아야 한다는 문제점 발생

 

비효율을 막기 위한 방법 2.

  • Rvalue Reference 사용
  • 임시 객체의 데이터를 복사하지 않고 이동(move)하는 방법.
    • 깊은 복사 대신 리소스 소유권을 이전하는 것

 


▶ Rvalue Reference

Copy 비효율을 막기 위한 방안 - 우측값 참조. 임시 객체를 참조하는 데 사용됨.

값만 복사하고 싶어. 메모리 자체를 복사하긴 싫어!
  • Lvalue : 메모리상에 존재하는 값.
    • & 를 통해 주소 받아올 수 있음.
    • move 불가능
  • Rvalue : 특정 메모리 위치를 가지지 않는 임시 객체.
    • 메모리상에서 얘를 접근할 방법이 없음. 즉, & 를 통해 주소 받아올 수 없음.
    • move 가능

이전에는 Rvalue와 Lvalue를 동일하게 처리하여, 불필요한 복사가 일어났음.

 

std::string s1 = "Hello ";
std::string s2 = "Mr.Hungry";

std::string&& s = s1 + s2;

이렇게 쓰면, s1 + s2 값에 직접 접근해서 s에 넣음.

 

 

 

내가 헷갈렸던 부분

 

* move 라는 건, 메모리상에서 옮길 수 있다는 뜻

int a = 3;

int b = 4;

일 때, a = b; 를 실행하면

b가 a에 들어가는 게 아니라,

b의 "값", 즉 b의 Rvalue가 a에 들어감.

a b

이게 아니고, 

a 4

이거.

 

 

 

* Xvalue, Prvalue

Rvalue는 xvalue와 prvalue로 나뉨

 

xvalue는 실행중에 생성되어 stack 공간에 임시로 올라간 값으로, life time이 끝나면 지워짐

즉 잠시동안 주소가 생기긴 함(우리가 얻어올 수는 없음)

 

prvalue는 사라지지 않음. 그냥 기본적으로 내장되어 있는 기본적인 숫자 같은 느낌

 

 

 

* 함수 정의할 때 parameter에 들어가는 애들은 무조건 lvalue

왜냐면 이름이 있고, 그 이름으로 접근할 수가 있기 때문.

 

예를들어, void Func(int&& a) 인 경우에

a는 int형 rvalue reference를 Type으로 갖는 lvalue임.

 

이 함수를 호출시,

int i = 1; 

Func(i); 

이렇게 호출한 경우, 

i는 lvalue, 1은 xvalue

 

그냥 바로

Func(3)

이렇게 호출한 경우,

3은 prvalue

 

 

또, 메모리에 올리기 전의 지역변수의 경우

우측 대입하는 값은 prvalue인듯. (함수 내에서 정의된 int f = 5; 이건 실행되기 전까지 prvalue)

얘가 호출되어 메모리에 올라가는 순간 xvalue로 잠시 올라감.

 


▷ Rvalue Reference - Move semantics

Rvalue Reference로 할 수 있는 일

unique ptr에서 나왔던 std::move를 Rvalue Reference 방식으로 구현

 

 

  • 복사 연산: 객체를 복사할 때 메모리를 할당하고 데이터를 복사해야 합니다.
  • 이동 연산: 객체를 이동할 때, 데이터의 소유권만 이동시키고 메모리 복사가 필요 없습니다.
    • std::move를 사용하여 리소스를 효율적으로 이전할 수 있습니다.
  • 결과: move sementic을 사용하면 불필요한 리소스 복사를 줄이고 성능을 최적화할 수 있습니다.

 

std::move는 객체를 Rvalue로 변환하는 함수,

  • Rvalue로 변환한다는 것은, 객체의 소유권을 이동할 준비를 한다는 의미
  • 변환 과정에서 객체의 메모리 구조 자체가 변경되지는 않지만, 객체가 더 이상 원래 데이터를 "소유"하지 않는다는 것을 나타내는 상태가 됨
  • 즉, 이동 연산 후 원래 객체의 포인터는 비어있거나 기본 상태로 초기화 됨.

 

 

예시1)

 

#include <vector>

std::vector<int> createVector() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    return std::move(vec);  // 임시 객체의 자원을 이동
}

int main() {
    std::vector<int> v = createVector();  // 복사 대신 이동이 발생
    return 0;
}

 

예시2)

#include <iostream>
#include <vector>

class MyClass {
public:
    std::vector<int> data;

    // 이동 생성자
    MyClass(std::vector<int>&& vec) : data(std::move(vec)) {
        std::cout << "Move constructor called!" << std::endl;
    }
};

int main() {
    std::vector<int> myVec = {1, 2, 3, 4, 5};
    MyClass obj(std::move(myVec));  // myVec의 데이터를 이동
    return 0;
}
  • 이 코드에서 일어나는 일:
    1. std::move(myVec) 호출:
      • std::move는 myVec을 Rvalue로 변환합니다. 이 변환은 myVec의 자원을 이동 가능 상태로 만들어줍니다. 이 시점에서 복사는 발생하지 않고, myVec의 소유권이 준비 상태가 됩니다.
    2. MyClass의 생성자 호출:
      • MyClass의 이동 생성자 MyClass(std::vector<int>&& vec)가 호출됩니다. 여기서 vec은 임시로 생성된 Rvalue 참조입니다. vec은 myVec의 자원을 소유하게 됩니다.
      • 이 과정에서 복사는 일어나지 않으며, myVec의 내부 데이터 포인터가 vec으로 이동됩니다.
    3. 멤버 변수 data에 자원 이동:
      • data(std::move(vec))를 통해, vec의 자원(즉, 벡터의 내부 데이터 포인터)이 data로 이동됩니다. 다시 말해, data는 이제 vec이 가지고 있던 자원을 소유합니다.
      • 복사는 여전히 일어나지 않습니다, 왜냐하면 std::move를 통해 자원의 소유권이 이동되었기 때문입니다.
    4. 결과:
      • myVec의 자원이 vec으로 이동하고, 다시 data로 이동합니다. 이 과정에서 데이터의 복사는 한 번도 일어나지 않고, 소유권이 이동됩니다.
      • myVec는 이제 비어 있거나 사용되지 않는 상태가 됩니다(내부적으로 비어 있는 상태로 남아 있을 수 있습니다).

 


◆ If-Init statement

너 초기화 되어있어?

 

C++ 17부터 도입된 문법.

 

if 문 안에서 변수의 선언과 초기화를 동시에 할 수 있게 해주는 것.

 

 

장점

 

  • 범위 제어 향상: if 문 안에서 선언된 변수는 해당 블록 안에서만 유효하므로, 불필요하게 외부에서 사용되는 것을 방지할 수 있음
  • 가독성: 초기화와 조건 검사를 한 줄에 작성할 수 있어 코드가 더 간결하고 읽기 쉬워짐
  • 안정성: 임시 객체나 복잡한 조건이 포함된 코드에서 변수의 범위를 더 안전하게 관리할 수 있음

 

 

 

If-Init 문 도입 전 예제

int value = someFunction();
if (value > 0) {
    // 'value' 사용 가능
}
// 'value'는 여기서도 여전히 접근 가능 (불필요한 범위 확장)

 

 

If-Init문 사용한 예제

if (int value = someFunction(); value > 0) {
    // 'value'는 여기서만 사용 가능
}
// 'value'는 여기서는 접근할 수 없음, 안전한 범위 제한

 


◆ Tuple

서로 다른 자료형의 여러 요소를 묶어서 리턴하고 싶을 때 튜플 이용

 

#include <iostream>
#include <tuple>

int main() {
    // 서로 다른 자료형의 요소를 튜플로 묶기
    std::tuple<int, std::string, double> myTuple = std::make_tuple(42, "Hello", 3.14);

    // 튜플의 요소에 접근하기
    std::cout << "첫 번째 요소: " << std::get<0>(myTuple) << std::endl;  // 출력: 42
    std::cout << "두 번째 요소: " << std::get<1>(myTuple) << std::endl;  // 출력: Hello
    std::cout << "세 번째 요소: " << std::get<2>(myTuple) << std::endl;  // 출력: 3.14

    return 0;
}

 

pair의 일반적인 버전. (pair은 원소 두 개만 몪음)

 

데이터베이스 레코드나 복합 데이터를 다룰 때 유용하게 사용 가능.

 


◆ Numeric Representation

수학 값 표현 방식에 관한 이야기

 


▶ Signed and unsigned integers

  • unsigned
    • 마이너스 표기할 필요 없음
    • 0x00000000부터 0xFFFFFFFF까지
  • signed
    • 부호와 크기 표기법(Sign and Magnitude):
      • 가장 왼쪽 비트는 부호 비트로, 0이면 양수, 1이면 음수를 나타내는 방식.
      • 하지만, 부호 비트 하나가 더 필요하므로 비효율적
      • +0과 -0이 존재한다는 문제점도 있음
      • 또, 양수와 음수를 연산할 때 별도의 회로가 필요함. (연산 방식이 다름)
    • 2의 보수 표기법 (Two’s Complement Notation):
      • 2의 보수를 이용해 음수를 표기하는 방식. 음수를 더 효율적으로 나타내기 위해 사용됨.
        • 2의 보수: 이진수 값을 반전(0과 1 변환)하고 1을 더한 값
          • 8비트 이진수에서 -5를 2의 보수로 표현
          • 5를 이진수로 표현: 00000101
          • 1의 보수(비트 반전): 11111010
          • 1을 더하기: 11111011
      • 0xFFFFFFFF는 -1을 의미하고, 음수 값은 0x80000000부터 0xFFFFFFFF까지 할당됩니다.
      • 부호 비트 표기법에 비해, 표현 가능한 범위가 넓어짐.
        • -2ⁿ⁻¹부터 2ⁿ⁻¹ - 1까지의 정수를 표현할 수 있음.
      • 2의 보수 표기법은 0에 대한 유일한 표현이 있음
      • 양수와 음수를 동일한 덧셈 연산으로 처리할 수 있음

 


▶ Fixed-point notation

.의 앞과 뒤를 나눠서 표현함

 

37.733 이런게 있을때 .의 위치가 중요함

처음엔 .을 기준으로 앞과 뒤를 나눠서 표현 했었음

 

근데 이런식으로면, 긴 소수를 표현할 때 문제점이 생김

  • 안쓰는 (앞)부분이 낭비되고
  • 저게 정확도가 부족해서 제대로 결과 안나옴

예를 들면 결과적으로

'7.7777 - 9999.9999' 가 '7.7777에 가장 근접한 표현가능한 숫자 - 9999.9999에 가장 근접한 표현가능한 숫자' 로 변함....ㅠㅠ

이런 문제를 해결하기위해, 부동 소수점 도입

 


▶ Floating-point notation

Sign + Exponent + Mantisa

 

 

  • Mantissa (가수): 소수점 앞뒤의 중요한 숫자를 저장합니다.
  • Exponent (지수): 소수점 위치를 나타냅니다.
  • Sign Bit (부호 비트): 숫자가 양수인지 음수인지를 나타냅니다.

 

9999.99999.9999999*10^3 이런식으로 표현

이렇게 표현하면 낭비 안하고 표현할수 있는 숫자가 많아짐

 

  • 일단 사인비트 1
  • 118.625를 2진수로 바꾸고 2의 보수로 표현하면 1110110.101
  • 여기서 .을 앞으로 가져옴. 1.110110101 * 2^6 으로 표현.
  • Exponent은 127 + 6 = 133이 됨. 
  • Mantisa는 아까 .뒤에 남은 110110101 저장하고, 남는 부분은 0으로 하면 11011010100000000000000

 


▷ 부동 소수점 계산시 주의점

이런거 절대 하면 안 됨

if (A.position == B.position)	//A와 B가 완전히 겹쳤을 때

 

왜냐하면, 보통 position은 부동소수점으로 저장되는데,

float-float가 완벽히 0이 되는 경우는 거의 없음

아무리 동일한 데이터더라도 실제로 빼면 0안나옴!

  • 범위 기준으로 계산하는 게 바람직함

'게임 > 게임 엔진 기초' 카테고리의 다른 글

6. Game Engine Support System (2)  (0) 2022.12.08
5. Game Engine Support System (1)  (1) 2022.12.08
3. Object-oriented programming (1)  (0) 2022.12.07
2. Game Engine Architecture (2)  (1) 2022.12.07
1. Game Engine Architecture (1)  (1) 2022.12.07
Comments