과거 특수문자 사용이 어려울 때 트라이그래프(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와 같은 비연속 메모리 구조를 사용하는 컨테이너에서는, 이터레이터가 요소의 메모리 주소를 직접 가리키는 대신, 노드 참조 등을 통해 포인터와 유사하게 구현됩니다.
이터레이터는포인터처럼 컨테이너의 요소를 가리키고, * 연산자를 사용하여 요소에 접근하거나 ++ 연산자를 사용하여 다음 요소로 이동할 수 있습니다.
포인터와 비슷한 방식으로컨테이너 요소를 순회하는 기능을 제공합니다.
차이점:
포인터는 단순히메모리 주소를 저장하는 변수입니다. 기본적으로 원시 데이터 타입이나 배열에 사용됩니다.
이터레이터는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
<주의할 점!>
한 객체를 소유하는 다른 포인터를 만들려면, 복사 생성자 또는 대입을 통해 초기화해줘야 함
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을 파라미터로 넘겨받아야 한다는 문제점 발생
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는 이제 비어 있거나 사용되지 않는 상태가 됩니다(내부적으로 비어 있는 상태로 남아 있을 수 있습니다).
◆ 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.9999를 9.9999999*10^3 이런식으로 표현
이렇게 표현하면 낭비 안하고 표현할수 있는 숫자가 많아짐
일단 사인비트 1
118.625를 2진수로 바꾸고 2의 보수로 표현하면 1110110.101
여기서 .을 앞으로 가져옴. 1.110110101 * 2^6 으로 표현.
Exponent은 127 + 6 = 133이 됨.
Mantisa는 아까 .뒤에 남은 110110101 저장하고, 남는 부분은 0으로 하면 11011010100000000000000