센로그
4. Object-oriented programming (2) 본문
◆ 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와 같은 비연속 메모리 구조를 사용하는 컨테이너에서는, 이터레이터가 요소의 메모리 주소를 직접 가리키는 대신, 노드 참조 등을 통해 포인터와 유사하게 구현됩니다.
- *iter를 통해 요소에 접근할 수 있는 것은 연산자 오버로딩 덕분입니다. 이터레이터 클래스는 operator*를 오버로딩하여, 포인터의 * 연산자처럼 동작하도록 구현되어 있습니다.
- *iter를 사용하는 방식 때문에 이터레이터가 포인터처럼 메모리 주소를 가리킨다고 생각할 수 있지만, 이터레이터 객체(iter) 자체가 곧 메모리 주소를 직접 저장하고 있는 것은 아닙니다. 이터레이터는 포인터처럼 보이지만, 추상화된 객체일 뿐입니다. STL에서 이터레이터는 연산자 오버로딩을 통해 포인터처럼 동작할 뿐이며, 실제로 메모리 주소를 직접적으로 저장하는지는 컨테이너에 따라 다릅니다.
- 복잡성을 줄이고 프로그램의 실행 시간을 최적화
※이터레이터 vs 포인터
차이점 및 유사점
- 유사점:
- 이터레이터는 포인터처럼 컨테이너의 요소를 가리키고, * 연산자를 사용하여 요소에 접근하거나 ++ 연산자를 사용하여 다음 요소로 이동할 수 있습니다.
- 포인터와 비슷한 방식으로 컨테이너 요소를 순회하는 기능을 제공합니다.
- 차이점:
- 포인터는 단순히 메모리 주소를 저장하는 변수입니다. 기본적으로 원시 데이터 타입이나 배열에 사용됩니다.
- 이터레이터는 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;
}
- 이 코드에서 일어나는 일:
- std::move(myVec) 호출:
- std::move는 myVec을 Rvalue로 변환합니다. 이 변환은 myVec의 자원을 이동 가능 상태로 만들어줍니다. 이 시점에서 복사는 발생하지 않고, myVec의 소유권이 준비 상태가 됩니다.
- MyClass의 생성자 호출:
- MyClass의 이동 생성자 MyClass(std::vector<int>&& vec)가 호출됩니다. 여기서 vec은 임시로 생성된 Rvalue 참조입니다. vec은 myVec의 자원을 소유하게 됩니다.
- 이 과정에서 복사는 일어나지 않으며, myVec의 내부 데이터 포인터가 vec으로 이동됩니다.
- 멤버 변수 data에 자원 이동:
- data(std::move(vec))를 통해, vec의 자원(즉, 벡터의 내부 데이터 포인터)이 data로 이동됩니다. 다시 말해, data는 이제 vec이 가지고 있던 자원을 소유합니다.
- 복사는 여전히 일어나지 않습니다, 왜냐하면 std::move를 통해 자원의 소유권이 이동되었기 때문입니다.
- 결과:
- myVec의 자원이 vec으로 이동하고, 다시 data로 이동합니다. 이 과정에서 데이터의 복사는 한 번도 일어나지 않고, 소유권이 이동됩니다.
- myVec는 이제 비어 있거나 사용되지 않는 상태가 됩니다(내부적으로 비어 있는 상태로 남아 있을 수 있습니다).
- std::move(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
- 2의 보수: 이진수 값을 반전(0과 1 변환)하고 1을 더한 값
- 0xFFFFFFFF는 -1을 의미하고, 음수 값은 0x80000000부터 0xFFFFFFFF까지 할당됩니다.
- 부호 비트 표기법에 비해, 표현 가능한 범위가 넓어짐.
- -2ⁿ⁻¹부터 2ⁿ⁻¹ - 1까지의 정수를 표현할 수 있음.
- 2의 보수 표기법은 0에 대한 유일한 표현이 있음
- 양수와 음수를 동일한 덧셈 연산으로 처리할 수 있음
- 2의 보수를 이용해 음수를 표기하는 방식. 음수를 더 효율적으로 나타내기 위해 사용됨.
- 부호와 크기 표기법(Sign and Magnitude):
▶ Fixed-point notation
.의 앞과 뒤를 나눠서 표현함
37.733 이런게 있을때 .의 위치가 중요함
처음엔 .을 기준으로 앞과 뒤를 나눠서 표현 했었음
근데 이런식으로면, 긴 소수를 표현할 때 문제점이 생김
- 안쓰는 (앞)부분이 낭비되고
- 저게 정확도가 부족해서 제대로 결과 안나옴
예를 들면 결과적으로
'7.7777 - 9999.9999' 가 '7.7777에 가장 근접한 표현가능한 숫자 - 9999.9999에 가장 근접한 표현가능한 숫자' 로 변함....ㅠㅠ
이런 문제를 해결하기위해, 부동 소수점 도입
▶ Floating-point notation
Sign + Exponent + Mantisa
- Mantissa (가수): 소수점 앞뒤의 중요한 숫자를 저장합니다.
- Exponent (지수): 소수점 위치를 나타냅니다.
- Sign Bit (부호 비트): 숫자가 양수인지 음수인지를 나타냅니다.
9999.9999를 9.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 |