삶의 공유

[C++] C++와 표준 라이브러리 초단기 속성코스(포인터, 참조, const, 타입추론) 본문

Programing/C++

[C++] C++와 표준 라이브러리 초단기 속성코스(포인터, 참조, const, 타입추론)

dkrehd 2021. 2. 3. 20:53
728x90
반응형

출저 : 책, 전문가를 위한 C++ 

 

메모리 공간을 적당히 할당하기만 하면 어떠한 값이라도 힙에 저장할 수 있다. 예를 들어 정수값을 힙에 저장하려면 정수 타입에 맞는 메모리 공간을 할당해야하는데 이때 다음과 같이 포인터를 선언해야 한다.

 

int* myIntegerPointer;

int 타입 뒤에 붙은 별표(*)는 이 변수가 정수 타입에 대한 메모리 공간을 가르킨다는 것을 의미한다. 이 때 포인터는 동적으로 할당된 힙 메모리를 가리키는 화살표와 같다.

아직 값을 할당 하지 않았기 때문에 포인터가 구체적으로 가리키는 대상은 없다. 이를 초기화 되지 않은 변수 라 부른다.

변수를 선언한 후에는 반드시 초기화 해야한다. 특히 포인터 변수는 초기화 하지 않으면 어느 메모리를 가르키는지 알수 없기 때문에 반드시 초기화 해야한다. 포인터를 초기화 하지 않고 사용하면 거의 대부분 프로그램이 뻗어버린다(crash). 그래서 포인터 변수는 항상 선언하자마자 초기화 한다. 포인터 변수에 메모리를 당장 할당하고 싶지 않으면 널 포인터(nullptr)로 초기화 한다.

int* myIntegerPointer = nullptr;

널 포인터란 정상적인 포인터라면 절대로 가지지 않을 특수한 값이며, 부을 표현식에서는 false로 취급한다.

포인터 변수에 메모리를 동적으로 할당할 때는 new 연산자를 사용한다

myIntegerPointer = new int;

이렇게 하면 정수값 하나에 대한 메모리 주소를 가리킨다. 이 포인터가 가리키는 값에 접근하려면 포인터를 역참조해야 한다. 역참조란 포인터가 힙에 있는 실제값을 가리키는 화살표를 따라 간다는 뜻이다. 앞에서 힙에 새로 할당한 공간에 정수값을 넣으려면 다음과 같이 작성한다.

myIntegerPointer = new int;
*myIntegerPointer = 8;

한가지 주의할 점은 이 문장은 myIntegerPointer = 8; 과는 전혀 다르다는 것이다. 이 문장에서 변경하는 값은 포인터(메모리 주소)가 아니라 이 포인터가 가리키는 메모리에 있는값이다. 

 

만약 메모리 주소가 8인 지점을 가리키는데, 거기에 의미없는 값이 담겨 있어서 이렇게 실행하면 프로그램이 뻗어 버릴 가능성이 높다

 

동적으로 할당한 메모리를 다 쓰고나면 delete연산자로 그 공간을 해제해야한다. 메모리를 해제한 포인터를 다시 사용하지 않도록 다음과 같이 곧바로 포인터 변수의 값을 nullptr로 초기화 하는 것이 좋다

delete myIntegerPointer;
myIntegerPointer = nullptr;

포인터는 힙뿐만 아니라 스택과 같은 다른 종류의 메모리를 가리킬 수도 있다. 원하는 변수의 포인터 값을 알고 싶다면 주소 참조 연산자인 &를 사용한다.

int i = 8;
int* myIntegetPointer = &i // 8이란 값을 가진 변수 i의 주소를 가리키는 포인터

C++는 구조체의 포인터를 다루는 부분을 조금 다르게 표현한다. 다시 말해 먼저 * 연산자로 역참조해서 구조체 자체(시작지점) 에 접근한 뒤 필드에 접근할때는 . 연산자로 표기한닷. 예를 들면 다음 코드와 같다 ※ 여기서는 getEmployee()함수가 Employee구조체를 리턴한다고 가정한다.

Employee* anEmployee = getEmployee();
cout << (*anEmployee).salary << endl;

코드가 조금 복잡해 보이는데, 좀 더 간결하게 표현하고 싶다면 ->(화살표) 연산자로 구조체를 역참조해서 필드에 접근하는 작업을 한 단계로 표현한다.

Employee* anEmployee = getEmployee();
cout << anEmployee->salary << endl;

포인터를 다룰 때 앞에서 소개한 단락 논리를 적용하면 잘못된 포인터에 접근하지 않게 할 수 있다.

bool isValidSalary = (anEmployee && anEmployee->salary > 0);

// 좀 더 길게 표현하면 다음과같다
bool isValidSalary = (anEmployee != nullptr && anEmployee->salary > 0);

anEmployee의 포인터 값이 올바를 때만 역참조해서 급여 정보를 가져온다. 이 값이 널 포인터면 단락 논리에 의해 연산을 중간에 멈추기 때문에 anEmployee 포인터를 역참조 하지 않는다.

 

동적으로 배열 할당하기

배열을 동적으로 할당할 때도 힙을 활용한다. 이때 new[] 연산자를 사용한다

int arraySize = 8;
int* myVariableSizedArray = new int[arraySize];

이렇게 메모리를 할당한 뒤에는 myVariableSizedArray를 일반 스택 기반 배열 처럼 다룰 수 있다.

myVariableSizedArray[3] = 2;

출저 : 책, 전문가를 위한 C++

배열을 위한 작업이 끝나면 다른 변수가 힙의 메모리 공간을 쓸수 있도록 이 배열을 힙에서 제거 한다.

delete[] myVariableSizedArray;
myVariableSizedArray = nullptr;

여기서 delete뒤에 붙은 대괄호는 배열을 삭제한다는 것을 의미한다.

※메모리 누수가 발생하지 않도록 new를 호출할 때마다 delete도 쌍을 이루도록 호출한다. new[]와 delete[] 도 마찬가지다. delete나 delete[]를 빼먹거나, 두 연산자가 쌍을 이루지 않으면 메모리 누수가 발생한다. 

 

널 포인터 상수

C++11 이전에는 NULL이란 상수로 널 포인터를 표현했다. NULL은 실제로 상수 0과 같아서 문제가 발생할 여지가 있다.

다음 코드를 살펴보자

 

#include <iostream>
using namespace std;

void func(char* str) { cout << "char* version" << endl; }
void func(int i) { cout << "int version" << endl; }

int main()
{
	func(NULL); // int version
}

main() 함수를 보면 func()를 호출할 때 매개변수로 널 포인터 상수인 NULL을 지정했다. 여기서는 char* 인수를 받는 버전의 func()를 호출하려고 널 포인터를 인수로 지정했지만, NULL은 포인터가 아니라 정수 0에 해당하기 때문에 정수 인수를 받는 버전의 func()가 호출 된다.

 

이럴 떄는 정식 널 포인터 상수인 nullptr를 사용한다.

 

스마트 포인터

스마트 포인터를 사용하면 메모리 관련하여 흔히 발생하는 문제를 방지 할수 있다. 스마트 포인터로 지정한 객체가 스코프를 벗어나면 메모리가 자동으로 해제 된다. C++에서 가장 중요한 스마트 포인터 타입은 두가지다. 둘다 <memory>헤더 파일에 정의돼 있으며 std네임스페이스에 속해 있다.

 - std::unique_ptr

 - std::shared_ptr

 

uique_ptr는 포인터로 가리키는 대상이 스코프를 벗어나거나 삭제될 때 할당된 메모리나 리소스도 자동으로 삭제된다는 점을 제외하면 일반 포인터와 같다. 그러나 unique_ptr가 가리키는 객체를 일반 포인터로는 가리킬수 없다. unique_ptr는 return문이 실행되거나 exception이 발생하더라도 항상 할당된 메모리나 리소스를 해제할수 있다는 장점이 있다.

unique_ptr를 생성할때는 반드시 std::make_unique<>()를 사용해야한다. 

1)일반적인 포인터 객체 생성방법

Employee* anEmployee = new Employee;
//...
delete anEmployee;

2) 스마트 포인터(unique_ptr생성방법)

auto anEmployee = make_unique<Employee>();

여기서 주목할 점은 delete가 자동으로 호출 되기 때문에 delete를 호출하는 문장을 따로 적을 필요가 없다는 것이다. 

auto로 지정하면 컴파일러가 변수의 타입을 추론하기 때문에 타입을 구체적으로 지정할 필요가 없다.

 

unique_ptr은 제네릭 스마트 포인터라서 어떠한 종류의 메모리도 가리킬수 있다. 그래서 템플릿으로 만든 것이다. 템플릿은 매개변수를 꺾쇠괄호(<>)로 묶어서 지정하는데, unique_ptr에서는 여기에 가리키려는 메모리 타입을 지정한다.

 

make_unique()는 C++14부터 추가 됐다. C++14를 지원하지 않는 컴파일러를 사용한다면 다음과 같은 방법으로 unique_ptr를 만든다. (이때 Employee 타입을 두번 명시해야 한다.)

unique_ptr<Employee> anEmployee(new Employee);

스마트 포인터로 지정한 anEmployee의 사용법은 일반 포인터와 같다. 예를 들면 다음과 같다.

if (anEmployee){
    cout << "Salary : " << anEmployee->salary << endl;
}

unique_ptr는 C 스타일 배열을 저장하는 데도 활용할 수 있다. 다음 예는 열 개의 Employee인스턴스로 구성된 배열을 생성하여 이를 unique_ptr에 저장하고, 배열에 담긴 원소를 접근하는 방법을 보여주고 있다.

auto Employees = make_unique<Employee[]>(10);
cout << "Salary : " << employee[0].salary << endl;

shared_ptr를 사용하면 데이터를 공유할수 있다. shared_ptr에 대한 대입 연산이 발생할때 마다 레퍼런스 카운트(참조횟수)가 하나씩 증가 한다. 그래서 shared_ptr가 가리키는 데이터를 레퍼런스 카운트 만큼 사용(소유) 하고 있다는 것을 표현한다.

shared_ptr가 스코프를 벗어나면 레퍼런스 카운트가 감소한다. 그러다 레퍼런스 카운트가 0이 되면 그 데이터를 아무도 가지고 있지 않기 때문에 포인터로 가리키던 객체를 해제한다.

 

shared_ptr는 std::make_shared<>()로 생성한다. 방법은 make_unique<>()와 비슷하다.

auto anEmployee = make_shared<Employee>();
if (anEmployee) {
	cout << "Salary : " << anEmployee->salary << endl;
}

C++17부터 shared_ptr에 배열도 저장할수 있다. 이전 버전에서는 이 기능을 지원하지 않는다. 하지만 C++17에서 배열을 저장하는 shared_ptr를 생성할때는 maker_shared<>()를 사용할수 없고, 다음과 같이 작성해야 한다.

shared_ptr<Employee[]> employees(new Employee[10]);
cout << "Salary: " << employee[0].salary << endl;

 

 

const의 다양한 용도

C++에서 const키워드는 다양하게 사용된다. 각각의 용도는 서로 관련돼 있지만 미묘한 차이가 있다. 그래서 면접 때 물어보기 딱 좋다.

 

cosnt 상수

const는 키워드 이름에서 알수 있듯이 상수와 관련이 이싿. C언어에서는 흔히 버전 번호처럼 프로그램을 실행하는 동안 변경하면 안되는 값에 이름을 붙일 때 전처리 구문인 #define을 사용했다.

C++에서는 #define대신 const로 정의하는 것이 바람직하다. const로 상수를 정의하는 방법은 변수를 정의할때와 거의 같고, 값이 변경되지 않도록 보장하는 작업은 컴파일러가 처리한다는 점만 다르다.

const int versionNumberMajor = 2;
const int versionNumberMinor = 1;
cout std::string productName = "Super Hyper Net Modulator"

const 매개변수

C++에서는 non-const변수를 const 변수로 캐스팅 할수 있다. 이렇게 하면 다른 코드에서 변수를 변경하지 않도록 어느정도 보호할수 있다. 동료가 작성한 함수를 호출할때 여러분이 전달한 매겨변수가 변경되지 않도록 보장하고 싶다면 동료에게 const매개변수를 받도록 함수를 작성해달라고 말하면 된다. 이렇게 작성한 함수 안에서 매개변수의 값을 변경하면 컴파일 오류가 발생한다.

 

다음 코드는 mysteryFunction()을 호출할 때 string* 을 const string*으로 자동 캐스팅 한다. 이 때 mysteryFunction()안에서 매개변수로 전달된 스트링의 값을 변경하면 컴파일 에러가 발생한다. 이러한 제약사항을 피하는 여러가지 방법이 있지만, 구현할 때 특히 주의를 기울여야 한다. C++에서는 const변수를 실수로 변경하는 경우만 보호해준다.

#include <iostream>
using namespace std;

void mysteryFunction(const string* someString)
{
	*someString = "Test"; // 이 부분에서 컴파일러 에러가 발생한다.
}
int main()
{
	string myString = "The string";
	mysteryFunction(&myString);
}

레퍼런스

C++제공하는 레퍼런스(참조)를 사용하면 기존 변수에 새 이름을 지정할수 있다.

예를 들면 다음과 같다.

int x = 42;
int& xReference = x;

변수의 타입 뒤에 &를 붙이면 그 변수는 레퍼런스가 된다. 코드에서 다루는 방법은 일반 변수와 같지만 내부적으로는 원본 변수에 대한 포인터로 취급한다. 앞에 나온 예에서 일반 변수 x와 레퍼런스 변수 xReference는 모두 같은 값을 가리킨다. 둘 중 한 변수에서 값을 변경하면 그 결과가 다른 변수에도 반영 된다.

 

레퍼런스 전달 방식

일반적으로 함수에 전달한 변수는 값 전달 방식(pass by value)으로 처리한다. 예를 들어 함수의 매개변수에 정수를 전달하면 함수 안에는 그 정수의 복제본이 전달된다. 따라서 함수 안에서 원본 변수의 값을 변경할 수 없다. C에서는 스택 변수에 대한 포인터를 자주 사용했는데, 이런 방식을 사용하면 다른 스택 프레임에 있는 원본 변수를 수정할 수 있다. 이러한 포인터를 역참조 하면 그 포인터가 현재 스택 프레임을 가리키지 않더라도 함수 안에서 그 변수가 가리키는 메모리의 값을 수정할 수 있다. 그런데 이 방식은 포인터 연산이 많아져서 간단한 작업이더라도 코드가 복잡해진다.

 

C++에서는 값 전달 방식 보다 뛰어난 레퍼런스 전달 방식(pass by reference)을 제공한다. 이 방식을 사용하면 매개변수가 포인터 값이 아닌 레퍼런스로 전달 된다. addOne()함수를 두가지 방식으로 구현한 코드를 살펴보자. 첫 번째 함수는 매개변수가 값으로 전달돼 함수안에서는 그 값의 복제본을 조작하기 때문에 원본 변수는 변하지 않는다. 두 번째 함수는 레퍼런스로전달되기 때문에 원본 변수의 값도 변경된다.

void addOne(int i)
{
    i++; // 복제본이 전달 됐기 때문에 원본에는 아무런 영향을 미치지 않는다.
}

void addOne(int& i)
{
    i++; // 원본 변수가 변경된다.
}

정수 타입에 대한 레퍼런스를 받는 addOne() 함수를 호출하는 문장을 작성하는 방식은 그냥 정수 값을 받는 함수를 호출할 때와 똑같다.

int myInt = 7;
addOne(myInt);

복제하는 데 부담스러울정도로 큰 구조체나 클래스를 리턴하는 함수를 구현할 때는 구조체나 클래스를 non-const레퍼런스로 받아서 원하는 작업을 수행한 뒤 그 결과를 직접 리턴하지 않고 내부에서 곧바로 수정하는 방식을 많이 사용한다. 함수에서 구조체나 클래스를 복제함으로써 발생하는 성능 저하를 최소화하기 위해 오래전부터 사용하던 방식이다.

 

const 레퍼런스 전달방식

함수의 매개변수를 const 레퍼런스로 전달하는 코드를 자주 볼수 있다. 얼핏 보면 모순되는 표현이라 생각할 수 있다. 원래 레퍼런스 매개변수를 사용하면 변수의 값을 다른 문맥에서 수정할 수 있는데, const로 지정해버리면 그렇게 할수 없는 것처럼 보이기 때문이다.

const 레퍼런스의 가장 큰 장점은 성능이다. 함수에 매개변수를 값으로 전달하면 그 값 전체가 복제된다. 하지만 레퍼런스로 전달하면 원본에 대한 포인터만 전달되기 때문에 원본 적체를 복제할 필요가 없다. 또한 const레퍼런스로 전달하면 복제 되지도 않고 원본 변수가 변경되지도 않는 장점을 모두 취할 수 있다.

const 레퍼런스는 특히 객체를 다룰때 유용하다. 객체듣 대체로 커서 복제하는 동안 의도하지 않은 효과가 발생할수 있기 때문이다. 이와 관련된 미묘한 이슈는 11장에서 자세히 소개한다. 다음 코드는 std::string을 함수에 const 레퍼런스로 전달하는 예를 보여주고 있다.

 

#include <iostream>
using namespace std;

void printString(const string& myString)
{
	cout << myString << endl;
}

int main()
{
	string myString = "Hello World";
	printString(myString);
	printString("Hello Wolrd"); // 리터러럴을 전달해도 된다. 
}

함수에 객체를 전달할때 값으로 전달하기 보다는 const레퍼런스로 전달하는 것이 좋다. 그러면 불필요한 복제 작업을 피할수 있다. 전달할 객체를 함수 안에서 수정하려면 non-const레퍼런스로 전달한다.

 

Exception

C++는 유연성이 굉장히 뛰어난 반면 안전성은 그리 좋지 않다. 메모리 공간을 무작위로 접근하거나 0으로 나누는 연산을 수행하더라도 컴파일러는 가만히 내버려둔다. (컴퓨터는 무한을 다룰 수 없음에도 불구하고) 이처럼 C++에 안전성을 좀 더 높이기 위해 제공하는 기능 중 하나로 익셉션(Exception, 예외)가 있다.

 

익셉션이란 예상하지 못한 상황을 표현하는 클래스/객체다. 예를 들어 웹페이지를 조회하는 함수에서는 다양한 문제가 발생할 수 있다. 그페이지를 제공하는 서버가 다운될 수도 있고, 빈페이지만 전달될 수도 있고, 인터넷 연결이 끊어질수도 있다,

이렇게 예상하지 못한 상황에 대처하는 한가지 방법은 그 함수에서 nullptr나 에러코드와 같은 특수한 값을 리턴하는 것이다. 익셉션을 활용하면 문제가 발생했을 때 좀 더 융통성 있게 대처할 수 있다.

 

익셉션 관련해서 몇가지 새로운 용어가 등장한다. 코드에서 특정한 조건을 만족해서 익셉션을 발생시키는 것을 익셉션을 던진다 (throw, 발생시킨다)고 표현하고, throw구문으로 작성한다. 또한 이렇게 발생된 익셉션에 대해 적절한 동작을 수행하는 것을 익셉션을 잡는다 (catch, 받는다, 처리한다)고 표현하고, catch구문으로 작성한다. 다음에 나오는 divideNumbers()란 함수를 이용해 익셉션을 던지고 잡는 에를 살펴보자. 이 함수는 전달된 분모의 인수가 0이면 익셉션을 발생시킨다.

여기서는 std::invalid_argument란 익셉션을 사용했는데, 이렇게 하려면 <stdexcept>란 헤더파일을 불러와야 한다.

 

#include <iostream>
#include <stdexcept>
using namespace std;

double divideNumbers(double numerator, double denominator)
{
    if(denominator == 0){
        throw invalid_argument("Donominator cannot be 0.");
    }
    return numerator / denominator;
}

int main()
{
    try{
        cout << divideNumbers(2.5, 0.5) << endl;
        cout << divideNumbers(2.5, 0) << endl;
    } catch(const invalid_argument& exception){
        cout << "Exception caught: " << exception.what() << endl;
    }
}
//output
// 5
// Exception caught: Donominator cannot be 0.

throw 문장이 실행되면 함수에서 값을 리턴하지 않고 실행을 즉시 중단한다. 이처럼 익셉션이 발생하는 함수를 호출할 때는 위의코드 처럼 try/catch블록으로 감싼다. 그러면 함수에서 익셉션을 발생할 때 적절히 대처할 수 있다.

 

C++에서 Exception을 제대로 처리하기 힘들 수도있다. Exception을 제대로 활용하려면 익셉션을 전질 시점에 스택 변수에서 어떤 일이 일어나는지 파악해야 한다. 그리고 발생한 익셉션 중에서 꼭 처리해야 할 것만 잡아서 적절히 대처해야한다. 앞에서는 C++에서 기본으로 제공하는 std::invalid_argument타입의 익셉션을 사용했는데, 기왕이면 발생할 수 있는 상황에 맞게 익셉션 타입을 직접 정의해서 사용하는 것이 좋다. C++컴파일러는 발생가능한 모든 익셉션 을 꼭 잡도록 강제하지 않는다. 익셉션을 처리한느 코드를 따로 작성하지 않으면 프로그램 자체 에서 처리하는데, 그러면 프로그램이 그냥 종료 된다.

 

타입 추론

타입 추론이란 표현식의 타입을 컴파일러가 스스로 알아내는 기능이다. 타입 추론과 관련된 키워드로 auto, decltype이 있다.

auto 키워드는 다음과 같이 다양한 상황에서 사용된다

 1) 함수의 리턴 타입을 추론한다. 

 2) 구조적 바인딩에 사용한다.

 3) 표현식의 타입을 추론하는데 사용한다.

 4) 비타입 템플릿 매개변수의 타입을 추론하는데 사용한다.

 5) decltype(auto)에서 사용한다.

 6) 함수에 대한 또 다른 문법으로 사용한다.

 7) 제네릭 람다 표현식에서 사용한다.

 

변수를 선언할 때 타입 자리에 auto 키워드를 지정하면 그 변수의 타입은 컴파일러 시간에 자동으로 추론해서 결정된다.

auto x = 123; // x는 int타입으로 결정된다.

이 예제 처럼 int대신 auto 쓸때는 이점이 없지만, 이보다 복잡한 타입에 적용할 떄는 편리하다.

예를 들어 복잡한 타입의 값을 리턴하는 getFoo()라는 함수가 있다고하자. getFoo()를 호출한 결과를 변수에 저장할 때 그 타입을 직접 나열해도 되지만 다음과 같이 간단히 auto를 붙이면 세부 작업은 컴파일러가 처리해준다.

auto result = getFoo();

또한 이렇게 하면 나중에 이 함수의 리턴 타입을 변경하더라도 코드에서 그 함수가 나온 모든 지점을 일일이 찾아서 고칠 필요없이 간단히 수정할 수 있다.

 

하지만 auto로 표현식의 타입을 추론하면 함수에 지정된 레퍼런스나 const한정자가 제거된다. 예를 들어 다음 함수를 살펴보자

#include <string>
const std::string message = "Test";

const std::string& foo()
{
    return message;
}

foo()를 호출해서 나온 결과를 auto타입으로 지정한 변수에 저장하려면 다음과 같이 작성한다.

auto f1 = foo();

auto를 지정하면 레퍼런스와 const한정자가 사라지기 때문에 f1은 string타입이 된다. 따라서 값이 복제돼버린다. const레퍼런스 타입으로 지정하려면 다음과 같이 auto 키워드 앞,뒤에 레퍼런스 타입과 const키워드를 붙인다.

const auto& f2 = foo();

※ auto를 지정하면 레퍼런스와 const지정자가 사라져서 값이 복제된다는 점에 주의한다. 복제 방식으로 전달되지 않게하려면 auto&나 const auto&로 지정한다.

 

반응형