정신과 시간의 방

전체 글 273

카테고리 설명
게임 프로그래머가 되기 위한 청춘의 기록
  • 정적 멤버 함수 이전에 우리는 클래스 내부에 구현해 놓은 기능에 접근하기 위해 범위 지정 연산자(::)를 사용해야 했다. 물론 멤버 함수의 경우에는 결국 객체가 필요하기 때문에 직접 접근해서 호출해 볼 순 없었지만 멤버 함수 중에는 정적 멤버 함수라는 것이 존재한다. 이런 정적 멤버의 특징은 멤버 함수이면서도 객체 없이 호출할 수 있다는 점이다. 정적 멤버 함수는 자료형 앞에 static 키워드를 붙여 선언할 수 있다. class CTest { private: int m_i; public: // 객체 없이 호출 가능 static void MemberFunc() { } }; int main() { CList list; for (int i = 0; i < 4; ++i) { list.push_back(i); ..

  • 함수 템플릿 함수 템플릿이란 함수를 찍어내는 틀과 같다. 만약 한 가지 기능을 하는 함수를 여러 데이터 타입이나 인자를 가지도록 만들어야 한다면 이전에 배웠던 함수 오버로딩을 사용하면 간단할 것이다. 함수 템플릿은 그것보다 더 간단하게 일종의 함수 틀을 만들어 주는 기능으로 다음과 같이 구현할 수 있다. template T Add(T a, T b) { return a + b; } 템플릿은 typename (혹은 class라고 해도 되지만, 우리가 이전에 배운 클래스와는 다른 개념이다. 헷갈릴 수 있으니 typename으로 표기하도록 하자)에 들어오는 자료형이 무엇이냐에 따라 그 자료형에 맞춰 작동한다. 즉, 함수를 만들 수 있는 이 템플릿을 선언해 놓고 사용하지 않으면 함수가 작동하지 않는 것이다. 그렇..

  • 우선 지난 시간까지 배웠던 개념들을 되짚어 보면, 우리가 데이터를 저장할 때 프로그램 실행 도중 계속 데이터가 확장될 수 있도록 하기 위해서는 주소가 필요했다. 자료형을 처음부터 아무리 크게 선언해도 결국 한계가 있기 때문이다. 그래서 힙 메모리 영역에 동적 할당을 해서 들어오는 새로운 데이터들을 넣어 주기로 했었다. int 데이터를 저장하는 가변 배열의 경우 int 포인터를 사용했다. 왜냐하면 새롭게 생성한 메모리를 할당한 곳을 int 데이터를 저장하는 목적으로 볼 것이기 때문이다. 거길 int 단위로 접근하고 수정하기 위해서는 int 포인터를 준비해서 그쪽 주소를 받아놓아야 한다. 지금부터는 클래스 필드를 선언해 볼 것이다. class CArr { private: int* m_pInt; int m_i..

  • 이전에 배웠던 구조체처럼 클래스 역시 내가 만든 자료형이라고 할 수 있다. 그러나 C가 C++로 넘어가면서, 클래스에서는 접근 제한 지정자라는 것을 사용할 수 있게 되었다. 접근 제한 지정자 private, protected, public으로 나뉜 접근 제한 지정자는 그 종류에 따라 해당 클래스에 접근할 수 있는 권한을 제한한다. 생성자와 소멸자 또한 기존의 구조체가 객체를 하나 만들어내면 그 객체를 초기화해 주는 함수를 개발자가 직접 호출해주어야 했던 것과 달리, 클래스는 생성자라는 초기화 함수를 객체 생성 시 자동으로 호출하여 사용자가 초기화 관련 실수를 하지 않도록 해준다. 그러나 이러한 생성자는 엄연히 말하자면 여러 단계를 거친다는 점에서 정석적인 초기화 방법이 아니다. 이를 보완하기 위해 사용하..

    C,C++

    클래스 NEW

    2023.01.28
    댓글
  • 이전에 배웠던 변수의 주소 뿐만 아니라 함수의 주소도 가져올 수 있다. 함수의 주소를 함수 포인터라고 한다. 함수는 이름 자체가 주소이기 때문에 인자로 넘길 때는 함수 이름만 쓰거나, 기존 방식대로 &함수이름 형식으로 써도 된다. 이를 활용하는 방법을 생각해보자. 지난 시간에 구현한 Sort() 함수에서 사용자가 어떤 정렬을 선택할 수 있을지 고를 수 있도록 해보려고한다. 내가 만든 정렬이 버블 정렬이라고 하면, 그 방식으로 정렬을 해 준다. 또 퀵 소트를 선택하면 그 방식으로 정렬해 준다. 이런식으로 함수를 전달하면 또 전달하는 방법이 있다고 가정하면 만든 기능을 인자로 전달할 수 있으므로 전달 받은 인자를 호출한 기능에서 사용하여 내부 데이터를 정렬하게 된다. 이를 위해 Sort() 함수를 다음과 같..

    C,C++

    함수 포인터 NEW

    2023.01.27
    댓글
  • 지난 시간에 배운 가변 배열과 마찬가지로 리스트도 데이터를 계속해서 추가할 수 있다. 그런데 데이터로 관리하는 방식이 가변 배열과 조금 다르다. 리스트와 가변 배열의 차이점 리스트에 배열이라는 이름이 붙지 않은 이유는 다음과 같다. 가변 배열의 특징은 힙 메모리에 메모리를 연속적으로 잡는다는 점이다. 그러나 이와 달리 리스트는 데이터 공간이 모자라면 그때마다 새로 할당하는 방식으로 작동한다. 할당된 최종 공간 기준으로 봤을 때 저장하고자 하는 데이터가 연속된 메모리로 힙 메모리 상에 일렬로 존재한다. 또한 포인터를 통해 인덱스에 접근이 가능하다. 기본 형태 자체는 기본 문법에서 제공하는 일반 배열 Array과 같아 보인다. 길이는 계속 확장될 수 있는 구조지만 힙 메모리 공간을 잡아 마치 배열처럼 쓸 수..

    C,C++

    리스트 NEW

    2023.01.26
    댓글
  • 지난 시간에 배웠던 동적 할당을 활용해 가변 배열을 만들어보자. 가변 배열이란 이전에 배웠던 크기가 고정되어 있는 일반 배열과 달리 크기에 변동이 생길 수 있는 배열이다. 따라서 아래처럼 지역변수를 사용할 수 없다. int main() { int a = 100; int iInput = 0; scanf_s("%d", &a); int arr[a] = {}; // 사용불가 return 0; } 같은 맥락으로 구조체도 살펴보자. 구조체 안에는 사용자가 임의로 변수를 멤버로 넣을 수 있다. 하지만 역시 이 구조체 안에도 배열 개수를 선언할 때는 다음과 같이 변수를 사용한 동적 배열은 만들 수 없다. int g_i = 100; typedef struct _tagST { int iArr[g_i]; // 사용불가 }S..

    C,C++

    가변 배열 NEW

    2023.01.24
    댓글
  • 메모리 동적 할당 및 관리 우리가 이전에 알아본 변수들은 다음과 같다. 스택 영역을 사용하는 지역 변수 데이터 영역을 사용하는 전역, 정적, 외부 변수 지금까지는 이러한 변수를 선언 및 정의하는 방법으로 메모리를 사용해왔다. 이런 방식으로 메모리를 사용할 때는 별다른 관리 책임이 따르지 않았다. 즉, 메모리를 할당하고 다시 운영체제에 반환하는 일련의 과정에 대해 프로그래머가 딱히 할 일이 없었다. 이런 변수들을 자동변수라고 한다. 하지만 자동 변수들의 경우 런타임 중 실시간으로 대응할 수 없다. 변수는 개발자가 이미 만들어놓은 데이터 영역으로 프로그램 시작 시 얼마큼 쓸지 미리 정해진 채로 사용되기 때문이다. 그러나 프로그램을 사용할 때는 종종 특정 변수가 필요할 수도 있고, 아닐 수도 있는 상황이 발생..

    C,C++

    동적할당 NEW

    2023.01.23
    댓글
카테고리
작성일
2023. 2. 1. 20:53
작성자
risehyun
  • 정적 멤버 함수

이전에 우리는 클래스 내부에 구현해 놓은 기능에 접근하기 위해 범위 지정 연산자(::)를 사용해야 했다.

물론 멤버 함수의 경우에는 결국 객체가 필요하기 때문에 직접 접근해서 호출해 볼 순 없었지만

멤버 함수 중에는 정적 멤버 함수라는 것이 존재한다.

 

이런 정적 멤버의 특징은 멤버 함수이면서도 객체 없이 호출할 수 있다는 점이다.

정적 멤버 함수는 자료형 앞에 static 키워드를 붙여 선언할 수 있다.

 

class CTest
{
private:
	int m_i;

public:
    // 객체 없이 호출 가능
	static void MemberFunc()
	{
	
	}
};
int main()
{
	CList<float> list;
	
	for (int i = 0; i < 4; ++i)
	{
		list.push_back(i);
	}

	// 정적 멤버 함수를 호출하고 싶을 때에는 해당 클래스 안에 선언되어 있는 함수를
	// 객체를 할당하지 않고도 사용할 수 있다.

	CTest::MemberFunc();

	return 0;
}

 

  • 네임스페이스(namespace)
    네임스페이스는 말 그대로 이름 공간이다.
    사용자가 원하는 이름을 입력하면 그 이름 안에 있는 요소들이 하나의 공간 안에 존재하게 된다.
    특이한 점은 이렇게 네임스페이스가 붙은 요소를 외부에서 접근하기 위해서는 반드시 네임스페이스를
    호출한 객체 앞에 붙여주어야 한다는 점이다.
namespace MYSPACE
{
    int g_int;
}


아래의 경우처럼 네임스페이스를 명시하지 않고 그냥 요소를 입력하면 인식되지 않는다.

g_int = 0;

 

올바른 접근 방법은 객체 앞에 네임스페이스를 명시해 주는 것이다.

흔히 사용되는, 사용자의 입력을 받는 cin 함수를 호출할 때 std::cin >> iInput;처럼 앞에 std::를 붙여주는 것과 동일하다.

MYSPACE::g_int = 0;

 

 

  • 왜 네임스페이스를 명시해야 할까?
    바로 라이브러리를 사용하는 사용자가 동일한 이름의 변수를 만들 수도 있기 때문이다. 이 경우 각 변수에 대한 구별이 정확히 되지 않기 때문에 문제가 발생할 수 있다. 
    이런 특징으로 인해 네임스페이스가 다르면 아래의 경우처럼 동일한 이름의 변수를 선언할 수 있게 된다.
namespace MYSPACE
{
    int g_int;
}

namespace OTHERSPACE
{
    int g_int;
}

 

하지만 현재 작업 중인 프로그램에서 중복된 이름이 존재하지 않는 변수까지 네임스페이스로 묶여버리면 중복 변수 간의 구분이 필요 없는데도 이 변수를 호출할 때마다 네임스페이스를 명시해주어야 하는 불편함이 발생한다.

 

이런 경우를 위해서 네임스페이스를 모두 해제하는 것이 아니라 특정 기능만 해제할 수 있는 기능이 존재한다.

using std::cout;
using std::endl;

 

using 키워드를 사용하면 함께 명시한 네임스페이스를 사용할 수 있도록 해준다.

이때 네임스페이스::해제할 기능을 선언하면 해당 네임스페이스에서 특정 기능만 해제하여

네임스페이스 명시 없이도 사용할 수 있게 된다.

cout << "안녕" << 10 << endl;

 

그런데 어떻게 이게 가능한 걸까? cout의 정의를 따라가보면 다음과 같이 ostream이라는 클래스로 만든 cout 객체가 외부 변수로 선언되어 있음을 확인할 수 있다.

 

이전에 배웠듯 외부 변수는 메모리 영역에 모든 곳에서 사용이 가능한 변수를 저장함으로써 프로그램 실행 중이라면 언제든지 원할 때마다 호출 할 수 있도록 하기 위해서 사용한다.

 

이때 << 역시 이전에 배웠던 연산자 오버로딩으로 구현된 것이다.

이번에는 직접 cout과 비슷한 기능을 하는 함수를 만들어 보자.

일반적인 cout 함수의 작동 결과를 생각해보면 숫자나 문자를 출력한다는 것을 알 수 있다.

이를 위해 각 자료형에 맞춘 인자를 가지도록 연산자를 오버로딩 해본다.

 

class CMyOStream
{
public:
	CMyOStream& operator << (int _idata) 
		              // _idata에는 호출시 사용자가 입력한 정수 값이 들어온다.
	{
		wprintf(L"%d", _idata);
		return *this; // 호출시 이 this에는 mycout의 주소가 들어온다.
	}

	CMyOStream& operator << (const wchar_t* _pString)
	{
		wprintf(L"%s", _pString);
		return *this;
	}
};

CMyOStream mycout;    // 1바이트 크기를 가짐

int main()
{
	//////// 비주얼스튜디오 언어 설정

	setlocale(LC_ALL, "korean");   // 출력 언어를 한글로 고정
	_wsetlocale(LC_ALL, L"korean");

	mycout << L"한글";

	return 0;
}

 

실행결과

 

이어서 endl 를 구현해보자. 이 endl역시 일종의 함수이다.

아래는 전체 코드이다.

#include <iostream>

#include "CList.h"

void MyEndL()
{
	wprintf(L"\n");
}


class CTest
{
private:
	int m_i;

public:
    // 객체 없이 호출 가능
	static void MemberFunc()
	{
	
	}
};

class CMyOStream
{
public:
	CMyOStream& operator << (int _idata) 
		              // _idata에는 호출시 사용자가 입력한 정수 값이 들어온다.
	{
		wprintf(L"%d", _idata);
		return *this; // 호출시 이 this에는 mycout의 주소가 들어온다.
	}

	CMyOStream& operator << (const wchar_t* _pString)
	{
		wprintf(L"%s", _pString);
		return *this;
	}

	CMyOStream& operator << (void(*_pFunc)(void))
	{
		_pFunc();
		return *this;
	}

	CMyOStream& operator >> (int& _idata)
	{
		scanf_s("%d", &_idata);
		return *this;
	}
};

CMyOStream mycout;    // 1바이트 크기를 가짐

int main()
{
	CList<float> list;
	
	for (int i = 0; i < 4; ++i)
	{
		list.push_back(i);
	}

	// 정적 멤버 함수를 호출하고 싶을 때에는 해당 클래스 안에 선언되어 있는 함수를
	// 객체를 할당하지 않고도 사용할 수 있다.

	CTest::MemberFunc();


	//////// 비주얼스튜디오 언어 설정

	setlocale(LC_ALL, "korean");   // 출력 언어를 한글로 고정
	_wsetlocale(LC_ALL, L"korean");

	mycout << L"한글";

	//int a = 0;
	// mycout >> a;
	mycout << MyEndL; // 참조 안에서 전달해준 주소를 받아서 그걸 MyEndL에서 호출해준다.

	mycout << 10 << L" " << 20 << L" " << L"문장" << MyEndL;

	return 0;
}

 

실행결과

'C,C++' 카테고리의 다른 글

[C++] iterator  (0) 2023.02.05
[C++] STL(vector, list)  (0) 2023.02.03
함수 템플릿, 클래스 템플릿, 클래스 템플릿 리스트  (0) 2023.01.31
클래스를 이용한 배열  (0) 2023.01.31
클래스  (0) 2023.01.28
카테고리
작성일
2023. 1. 31. 20:50
작성자
risehyun
  • 함수 템플릿

함수 템플릿이란 함수를 찍어내는 틀과 같다.

만약 한 가지 기능을 하는 함수를 여러 데이터 타입이나 인자를 가지도록 만들어야 한다면

이전에 배웠던 함수 오버로딩을 사용하면 간단할 것이다.

 

함수 템플릿은 그것보다 더 간단하게 일종의 함수 틀을 만들어 주는 기능으로 다음과 같이 구현할 수 있다.

template<typename T>
T Add(T a, T b)
{
    return a + b;
}

 

템플릿은 typename (혹은 class라고 해도 되지만, 우리가 이전에 배운 클래스와는 다른 개념이다.

헷갈릴 수 있으니 typename으로 표기하도록 하자)에 들어오는 자료형이 무엇이냐에 따라 그 자료형에 맞춰 작동한다.

즉, 함수를 만들 수 있는 이 템플릿을 선언해 놓고 사용하지 않으면 함수가 작동하지 않는 것이다.

그렇다고 해서 템플릿을 여러번 호출한다고 해서 함수가 여러 개 생기는 것도 아니다.

하나의 자료형에 대해서 템플릿 함수는 딱 하나만 만들어진다.

 

이렇게 만들어낸 템플릿은 다음과 같이 사용할 수 있다.

// 함수 템플릿
int i = Add<int>(10, 20); // T 자리에 int가 들어간다.
Add<int>(10, 20);

 

또한 컴파일러가 템플릿 사용시 들어가는 값을 적절히 인식해 줄 때가 있으므로, 아래와 같이 자료형을 명시하지 않아도

들어간 값이 어떤 타입인지를 컴파일러가 분석해서 그에 맞춰 작동시키기도 한다.

하지만 이렇게 자료형을 생략한다고 해서 함수 템플릿 Add가 일반 함수가 되는 것은 아니다.

하나의 틀이 있고 그 틀에서 뽑혀 나온 물건이 있다고 한다면 그 두 개가 같다고 할 수 없는 것처럼,

자료형을 생략해도 함수 템플릿은 여전히 템플릿을 통해서 만들게 된 함수를 호출하는 것으로 작동한다.

int i = Add(10, 20);

 

이처럼 함수 템플릿을 사용하면 공통된 기능을 하는 함수를 대입되는 자료형만 바꿔서 쉽게 사용할 수 있다.

 

  • 클래스 템플릿
    지금까지 우리가 구현한 가변 배열 클래스를 살펴보면 멤버 변수들이 모두 int로 선언되어 있어 해당 자료형만 저장할 수 있다. 만약 다른 자료형을 사용하고 싶다면 또 가변 배열을 하나 더 만들어야 할 것이다. 이런 식으로 한다면 정말 많은 클래스를 만들어야 할 것이므로 상당히 비효율적이다.

    그렇다면 기본 데이터 타입이 아니라 내가 만든 클래스나 구조체를 저장하는 배열을 만들고 싶을 때는 어떻게 해야 할까? 우리가 앞서 살펴본 함수 템플릿처럼, 클래스를 템플릿으로 만든다면 이런 상황에서 클래스 템플릿에 필요한 자료형만 대입하면 언제든 원하는 대로 클래스를 활용할 수 있을 것이다.

    클래스 템플릿을 만드는 방법은 아주 간단하다.
    만들어진 클래스의 최상단에 아래와 같이 template<typename T>를 기입해 주는 것이다.
    클래스 템플릿으로 선언되면 해당 클래스는 클래스가 아닌 클래스 템플릿이 된다.
#pragma once

template<typename T>
class CArr
{
private:
	int* m_pInt;
	int  m_iCount;
	int  m_iMaxCount;


public:
	void push_back(int _Data);
	void resize(int _iResizeCount);
    
    int operator[] (int idx);


public:
	CArr();
	~CArr();

};

 

이렇게 선언하면 기존의 코드에서 하나의 문제가 발생한다.

CArr carr;
carr.push_back(10);
carr.push_back(20);
carr.push_back(30);

 

현재 1번 라인의 코드 CArr carr는 CArr가 클래스 일 때 작성한 코드이다.

하지만 이제 CArr가 클래스가 아닌 클래스를 만들 수 있는 더욱 근본적인 원형, 클래스 템플릿이 되었으므로

사용하기 위해서는 타입을 정해주어야 한다. 그래야 그런 타입을 사용하는 버전의 클래스가 생성될 것이고

생성된 클래스를 가지고 활용할 수 있게 된다. 이에 맞춰서 코드를 수정해보자.

 

<typename T>의 T가 해당 클래스에서 사용할 데이터 타입을 의미한다.

따라서 기존의 클래스에서 사용하던 int 포인터는 T 포인터가 되어야 한다.

 

데이터를 추가하는 PushBack 함수도 T타입을 가지되 어떤 크기의 데이터가 들어올지 알 수 없으므로, 큰 데이터가 들어오는 경우까지 가정해서 참조를 넘기기 위해 레퍼런스를 가져야 한다.

 

resize 함수의 경우 확장할 데이터의 수를 표시하기 때문에 기존에 선언한 그대로 int 타입을 사용한다.

하지만 [] 연산자는 참조하는 대상의 T 타입들이 저장되어 있기 때문에 T 타입으로 바꿔야 한다.

#pragma once

template<typename T>
class CArr
{
private:
	T* m_pData;
	int  m_iCount;
	int  m_iMaxCount;


public:
	void push_back(int _Data);
	void resize(int _iResizeCount);

	T& operator[] (int idx);

public:
	CArr();
	~CArr();

};

 

클래스 템플릿을 만들 때 주의해야 할 사항 중 하나는 템플릿의 함수들이 헤더 파일에 있어야 한다는 것이다.

반드시 함수들이 cpp 파일에 있어선 안된다.

 

왜냐하면 우리가 그동안 컴파일 과정을 

내가 무언가를 만들어 놓으면 컴파일이 이 코드들을 파일 단위로 컴파일하여 링크 과정을 통해서 다시 합치는 과정을 거쳤다. 

 

만약에 클래스 템플릿을 만들어 놨는데, 이 함수들이 cpp에 있다면 각 데이터 타입 부분에 T가 들어갈 것이다.

그리고 이 데이터 타입이 무엇인지 정해지면 그때 거기에 T가 들어간 버전의 함수들이 생성된다.

 

그런데 생각을 해보면, main.cpp에서 이번에 만든 클래스 템플릿 헤더를 참조하면 헤더 파일에는 함수에 대한 선언만 몰려 있는 상황이므로 거기에 T 자리를 넣어준 버전의 함수만 생성되고, 실제로 사용할 float이나 int타입에 해당하는 함수는 생성되지 않는다. 따라서 main.cpp에서는 float이나 int타입이 들어간 버전의 함수들이 있을 것이라고 판단하여 선언은 만들어지지만 실제로는 그런 타입의 함수가 구현되지 않았으므로 문제가 발생한다.

 

따라서 클래스 템플릿은 구현 부분도 전부 cpp 파일이 아닌 헤더 파일에 선언해야 한다.

그래야 메인 쪽 cpp 파일을 컴파일할 때 요청된 데이터 형식에 맞춰서 정상적으로 함수가 구현(생성) 되기 때문이다.

 

또한, 이렇게 헤더 파일에 선언된 함수들은 다음과 같이 함수 템플릿 선언이 있어야 정상 작동한다.

생성자 쪽 코드를 자세히 보면 생성자는 생성자인데, 이것 자체는 아직 완성된 함수가 아니라 구현 부분을 적으려고 하는 템플릿이므로 템플릿이라고 명시를 해주어야 한다. 이와 같이 함수 템플릿으로 바뀌면서 수정해주어야 할 부분을 반영한 코드는 다음과 같다.

 

#pragma once
#include <assert.h>

template<typename T>
class CArr
{
private:
	T* m_pData;
	int  m_iCount;
	int  m_iMaxCount;


public:
	void push_back(int _Data);
	void resize(int _iResizeCount);

	T& operator[] (int idx);

public:
	CArr();
	~CArr();

};

template<typename T>
CArr<T>::CArr()
    : m_pData(nullptr)
    , m_iCount(0)
    , m_iMaxCount(2)
{
    m_pData = new T[2]; // 함수 템플릿이 되었으므로 기존의 INT 대신 T가 존재한다.
}

template<typename T>
CArr<T>::~CArr()
{
    delete[] m_pData;
}

template<typename T>
void CArr<T>::push_back(const T& _Data)
{
    if (this->m_iMaxCount <= this->m_iCount) // 힙 영역에 할당한 공간이 다 찬 경우
    {
        // 재할당
        resize(m_iMaxCount * 2);
    }

    // 데이터 추가
    m_pData[m_iCount++] = _Data;

}

template<typename T>
void CArr<T>::resize(int _iResizeCount)
{
    // 현재 최대 수용량 보다 더 적은 수치로 확장하려는 경우 예외처리함
    if (m_iMaxCount >= _iResizeCount)
    {
        assert(nullptr);
    }

    // 1. 리사이즈 시킬 개수만큼 동적할당 한다
    T* pNew = new T[_iResizeCount];

    // 2. 기존 공간에 있던 데이터들을 새로 할당한 공간으로 복사시킨다.
    for (int i = 0; i < m_iCount; ++i)
    {
        pNew[i] = m_pData[i];
    }

    // 3. 기존 공간은 메모리 해제
    delete[] m_pData;

    // 4. 배열이 새로 할당된 공간을 가리키게 한다.
    m_pData = pNew;

    // 5. MaxCount 변경점 적용
    m_iMaxCount = _iResizeCount;


}

template<typename T>
T& CArr<T>::operator[] (int idx)
{
    return m_pData[idx];
}

 

이렇게 바뀐 함수 템플릿을 사용하는 방법은 다음의 코드처럼 T 자리에 들어갈 데이터 타입을 명시하는 것이다.

CArr<int> carr;

 

  • 클래스 템플릿 리스트
    이번에는 이전에 구현해 보았던 연결형 리스트를 클래스로 바꿔보면서 클래스 템플릿 리스트에 대해 알아보겠다.

    기존의 tagList에서는 구조체로 단방향 연결형 리스트를 구현하였다.
typedef struct _tagList
{
    tNode* pHeadNode;
    int    iCount;
}tLinkedList;


이번에는 클래스로 양방향 리스트 CList를 구현해본다.

template<typename T>
class CList
{
private:
    tListNode<T>* m_pHead;     // 포인터 타입으로 주소의 시작 경로를 알고 있는 변수가 필요하다.
    tListNode<T>* m_pTail;     // 양방향 리스트이므로 Tail 값을 알고 있는 변수가 필요하다.
    int           m_iCount;    // 데이터 개수를 카운팅할 변수가 필요하다.

public:
    void push_back(const T& _data);  // 타입 T값이 미정 상태이기 때문에, 복사 비용을 줄이기 위해
    void push_front(const T& _data); // 레퍼런스로 받아 원본 자체는 수정하지 않는 참조를 받아와서
                                     // 우리쪽 노드 단위로 데이터를 받아 온 것을 관리한다.

public:
    CList();
    ~CList();
};

 

이번에는 List 구현의 기본이 되는 Node를 구현해본다.

C++에서 클래스라는 개념이 새롭게 등장했지만 이는 이전에 C에서 사용하던 구조체와 구조, 기능적으로 동일하다.

그러나 여기서 다룰 Node의 기능은 단순히 데이터를 묶고, 데이터를 저장하는 정도로 단순하기 때문에

이번에는 구조체로 Node를 구현해본다.

 

template<typename T>
struct tListNode // C++쪽 기준으로는 구조체와 클래스는 거의 유사하다고 볼 수 있다.
                 // 노드도 클래스로 선언해도 상관없지만 여기서는
                 // 기능이 그렇게 많지 않을 때, 단순히 데이터를 묶어 놓고 그 데이터를 저장하는
                 // 정도의 목적만 있어서 그렇게 많은 기능을 지원하지 않을 때는 구조체 키워드를 써본다.
{
    T             data;
    tListNode<T>* pNext;
    tListNode<T>* pPrev;

    // C++에서는 구조체와 클래스가 똑같기 때문에 구조체도 생성자를 만들 수 있다.
    tListNode()                                                           // 인자값이 없는 경우 생성
        : data()
        , pNext(nullptr)
        , pPrev(nullptr)
    {
    }

    tListNode(const T& _data, tListNode<T>* _pPrev, tListNode<T>* _pNext) // 인자값을 넣은 경우 생성
        : data(_data)
        , pPrev(_pPrev)
        , pNext(_pNext)
    {
    
    }
};

 

이어서 데이터를 추가하는 두 가지 함수를 구현해보자.

template<typename T>
void CList<T>::push_back(const T& _data)
{
    tListNode<T>* pNewNode = new tListNode<T>; (_data, nullptr, nullptr);

    // 처음 입력된 데이터라면 Head와 Tail을 자기 자신으로 채운다
    if (nullptr == m_pHead)
    {
        m_pHead = pNewNode;
        m_pTail = pNewNode;
    }

    else
    {
        // 데이터가 1개 이상에서 입력된 경우
        // 현재 가장 마지막 데이터(tail) 를 저장하고 있는 노드와
        // 새로 생성된 노드가 서로 가리키게 한다.
        m_pTail->pNext = pNewNode;
        pNewNode->pPrev = m_pTail;

        // List가 마지막 노드의 주소값을 새로 입력된 노드로 갱신한다.
        m_pTail = pNewNode;

    }

    // 데이터 개수 증가
    ++m_iCount;

}

template<typename T>
void CList<T>::push_front(const T& _data)
{
    // 새로 생성된 노드의 다음을 현재 헤드노드의 주소값으로 채움
    tListNode<T>* pNewNode = new tListNode<T>(_data, nullptr, m_pHead);

    // 현재 헤드노드의 이전노드 주소값을 새로 생성된 노드의 주소로 채움
    m_pHead->pPrev = pNewNode;

    // 리스트가 새로 생성된 노드의 주소를 새로운 헤드주소로 갱신한다.
    m_pHead = pNewNode;

    // 데이터 개수 증가
    ++m_iCount;

}

 

해당 CList 클래스의 생성자와 소멸자는 다음과 같이 작성한다.

template<typename T>
CList<T>::CList()
    : m_pHead(nullptr)
    , m_pTail(nullptr)
    , m_iCount(0)
{

}

template<typename T>
CList<T>::~CList()
{
    tListNode<T>* pDeletNode = m_pHead;

    while (pDeletNode)
    {
        tListNode<T>* pNext = pDeletNode->pNext;
        delete(pDeletNode);
        pDeletNode = pNext;
    }
}

 

헤더 파일 전체 소스 코드이다.

#pragma once

template<typename T>
struct tListNode // C++쪽 기준으로는 구조체와 클래스는 거의 유사하다고 볼 수 있다.
                 // 노드도 클래스로 선언해도 상관없지만 여기서는
                 // 기능이 그렇게 많지 않을 때, 단순히 데이터를 묶어 놓고 그 데이터를 저장하는
                 // 정도의 목적만 있어서 그렇게 많은 기능을 지원하지 않을 때는 구조체 키워드를 써본다.
{
    T             data;
    tListNode<T>* pNext;
    tListNode<T>* pPrev;

    // C++에서는 구조체와 클래스가 똑같기 때문에 구조체도 생성자를 만들 수 있다.
    tListNode()                                                           // 인자값이 없는 경우 생성
        : data()
        , pNext(nullptr)
        , pPrev(nullptr)
    {
    }

    tListNode(const T& _data, tListNode<T>* _pPrev, tListNode<T>* _pNext) // 인자값을 넣은 경우 생성
        : data(_data)
        , pPrev(_pPrev)
        , pNext(_pNext)
    {
    
    }
};

template<typename T>
class CList
{
private:
    tListNode<T>* m_pHead;     // 포인터 타입으로 주소의 시작 경로를 알고 있는 변수가 필요하다.
    tListNode<T>* m_pTail;     // 양방향 리스트이므로 Tail 값을 알고 있는 변수가 필요하다.
    int           m_iCount;    // 데이터 개수를 카운팅할 변수가 필요하다.

public:
    void push_back(const T& _data);  // 타입 T값이 미정 상태이기 때문에, 복사 비용을 줄이기 위해
    void push_front(const T& _data); // 레퍼런스로 받아 원본 자체는 수정하지 않는 참조를 받아와서
                                     // 우리쪽 노드 단위로 데이터를 받아 온 것을 관리한다.

public:
    CList();
    ~CList();
};

template<typename T>
void CList<T>::push_back(const T& _data)
{
    tListNode<T>* pNewNode = new tListNode<T>; (_data, nullptr, nullptr);

    // 처음 입력된 데이터라면 Head와 Tail을 자기 자신으로 채운다
    if (nullptr == m_pHead)
    {
        m_pHead = pNewNode;
        m_pTail = pNewNode;
    }

    else
    {
        // 데이터가 1개 이상에서 입력된 경우
        // 현재 가장 마지막 데이터(tail) 를 저장하고 있는 노드와
        // 새로 생성된 노드가 서로 가리키게 한다.
        m_pTail->pNext = pNewNode;
        pNewNode->pPrev = m_pTail;

        // List가 마지막 노드의 주소값을 새로 입력된 노드로 갱신한다.
        m_pTail = pNewNode;

    }

    // 데이터 개수 증가
    ++m_iCount;

}

template<typename T>
void CList<T>::push_front(const T& _data)
{
    // 새로 생성된 노드의 다음을 현재 헤드노드의 주소값으로 채움
    tListNode<T>* pNewNode = new tListNode<T>(_data, nullptr, m_pHead);

    // 현재 헤드노드의 이전노드 주소값을 새로 생성된 노드의 주소로 채움
    m_pHead->pPrev = pNewNode;

    // 리스트가 새로 생성된 노드의 주소를 새로운 헤드주소로 갱신한다.
    m_pHead = pNewNode;

    // 데이터 개수 증가
    ++m_iCount;

}

template<typename T>
CList<T>::CList()
    : m_pHead(nullptr)
    , m_pTail(nullptr)
    , m_iCount(0)
{

}

template<typename T>
CList<T>::~CList()
{
    tListNode<T>* pDeletNode = m_pHead;

    while (pDeletNode)
    {
        tListNode<T>* pNext = pDeletNode->pNext;
        delete(pDeletNode);
        pDeletNode = pNext;
    }
}

 

 

이제 리스트에 값을 직접 넣어보고 정상 작동하는지 확인해 보자.

 

<main.cpp>

#include <iostream>

#include "CList.h"

int main()
{
	CList<float> list;
	
	for (int i = 0; i < 4; ++i)
	{
		list.push_back(i);
	}

	return 0;
}

 

총 4개의 노드가 성공적으로 저장되었음을 확인할 수 있다.

'C,C++' 카테고리의 다른 글

[C++] STL(vector, list)  (0) 2023.02.03
namespace, 입출력 구현 (cin, cout)  (0) 2023.02.01
클래스를 이용한 배열  (0) 2023.01.31
클래스  (0) 2023.01.28
함수 포인터  (0) 2023.01.27
카테고리
작성일
2023. 1. 31. 12:32
작성자
risehyun

우선 지난 시간까지 배웠던 개념들을 되짚어 보면,

우리가 데이터를 저장할 때 프로그램 실행 도중 계속 데이터가 확장될 수 있도록 하기 위해서는 주소가 필요했다.

자료형을 처음부터 아무리 크게 선언해도 결국 한계가 있기 때문이다.

 

그래서 힙 메모리 영역에 동적 할당을 해서 들어오는 새로운 데이터들을 넣어 주기로 했었다.

int 데이터를 저장하는 가변 배열의 경우 int 포인터를 사용했다.

왜냐하면 새롭게 생성한 메모리를 할당한 곳을 int 데이터를 저장하는 목적으로 볼 것이기 때문이다.

거길 int 단위로 접근하고 수정하기 위해서는 int 포인터를 준비해서 그쪽 주소를 받아놓아야 한다.

 

지금부터는 클래스 필드를 선언해 볼 것이다.

 

<CArr.h>

class CArr
{
    private:
        int*    m_pInt;
        int     m_iCount;
        int     m_iMaxCount;
        
public:
     CArr();
     ~CArr();
};

 

이전에 배웠던 구조체는 무조건 공개 상태에 있었다.

하지만 클래스에서는 접근 제한자를 사용해서 외부에 대한 멤버 공개 여부를 직접 설정해줄 수 있다. 

여기서는 접근 제한자를 private 로 설정해 외부로부터 클래스 멤버를 보호할 것이다.

 

이때 만약 클래스에 아무것도 적지 않는다면 해당 멤버는 기본적으로 디폴트 접근 제한 지정자인 private를 따르게 된다.

하지만 위의 헤더 파일에서는 직관적으로 코드를 이해하기 위해서 명시적으로 private 표시를 해주었다.

 

이어서 아래에는 public 상태로 생성자와 소멸자를 만들어주었다.

특별히 public 상태로 선언한 이유는 private로 생성자와 소멸자를 선언하면

접근이 제한되어 main.cpp의 메인 함수에서 호출할 수 없기 때문이다.

 

생성자와 소멸자는 따로 만들지 않아도 기본적으로 컴파일러가 생성을 해주지만,

현재 코드는 추후 생성자와 소멸자에서 수행해야 할 일이 있기 때문에 명시해주었다.

 

이제 헤더에 기본적인 틀을 만들었으니 아래의 cpp 파일에서 구체적인 구현을 해보자.

클래스에서의 동적 할당은 new 키워드로, 메모리 해제는 delete 키워드로 할 수 있다.

활용 예시 코드는 다음과 같다.

 

<CArr.cpp>

#include "CArr.h"

CArr::CArr()           // 초기화시 헤더 파일에서 작성한 순서대로 초기화해야 성능상 더 빠르다.
    : m_pInt(nullptr)
    , m_iCount(0)
    , m_iMaxCount(2)
{
    m_pInt = new int[2]; // malloc 함수의 C++ 버전. int크기 2개만큼을 할당하므로 8바이트 할당과 같다
}

CArr::~CArr()
{
    delete[] m_pInt;      // free 함수의 C++ 버전. 
                          // m_pInt를 지울 건데 그곳에 가면 인트가 연속적으로 있다는 의미

//    int* p = new int[10]; // 배열로 변수 여러 개를 한번에 동적할당했을 때 이걸 한번에 지우려면
//    delete[] p;           // [] 을 사용해야 한다.

}

구현 파트에서는 생성자와 소멸자에 접근하기 위해, 클래스 내부에 선언한 기능에 접근하기 위한 범위 지정 연산자(::)를 사용했다.

 

<main.cpp>

// C++ 동적할당 new, delete
CTest* pTest = new CTest;
delete pTest;

 

이어서 우리가 가변 배열을 구현할 때 지원 되는 서포팅 함수들이 몇가지 있었다.

현재 클래스에서는 생성자와 소멸자에서 할당과 해제 처리를 해주기 때문에 우리가 구현 해야 할 것은 PushBack 함수와 저장 데이터 공간이 부족할 때 이를 확장시켜줄 resize 함수다.

이때 멤버 함수라는 것은 객체를 통해 호출 되기 때문에 아래와 같이 들어올 데이터만 적어주면 된다.

 

<CArr.h>

#pragma once
class CArr
{
private:
	int* m_pInt;
	int  m_iCount;
	int  m_iMaxCount;


public:
	void push_back(int _Data);
	void resize(int _iResizeCount);


public:
	CArr();
	~CArr();

};

 

<CArr.cpp>

#include "CArr.h"
#include <cassert>

CArr::CArr()
    : m_pInt(nullptr)
    , m_iCount(0)
    , m_iMaxCount(2)
{
    m_pInt = new int[2];
}

CArr::~CArr()
{
    delete[] m_pInt;

    int* p = new int[10];
    delete[] p;

}


void CArr::push_back(int _Data)
{
	if (this->m_iMaxCount <= this->m_iCount) // 힙 영역에 할당한 공간이 다 찬 경우
	{
		// 재할당
        resize(m_iMaxCount * 2);
	}

	// 데이터 추가
	m_pInt[m_iCount++] = _Data;

}

void CArr::resize(int _iResizeCount)
{
    // 현재 최대 수용량 보다 더 적은 수치로 확장하려는 경우 예외처리함
    if (m_iMaxCount >= _iResizeCount)
    {
        assert(nullptr);
    }

    // 1. 리사이즈 시킬 개수만큼 동적할당 한다
    int* pNew = new int[_iResizeCount];

    // 2. 기존 공간에 있던 데이터들을 새로 할당한 공간으로 복사시킨다.
    for (int i = 0; i < m_iCount; ++i)
    {
        pNew[i] = m_pInt[i];
    }

    // 3. 기존 공간은 메모리 해제
    delete[] m_pInt;

    // 4. 배열이 새로 할당된 공간을 가리키게 한다.
    m_pInt = pNew;

    // 5. MaxCount 변경점 적용
    m_iMaxCount = _iResizeCount;


}

이제 구현한 클래스를 활용해보자.

 

<main.cpp>

#include <iostream>

#include "Arr.h"
#include "CArr.h"

class CTest
{
private:
	int a;

public:
	CTest()
		: a(10)
	{
	
	}
};

int main()
{
	// 기존의 가변 배열 사용시 방법

	tArr arr = {};
	InitArr(&arr);

	PushBack(&arr, 10);
	PushBack(&arr, 20);
	PushBack(&arr, 30);

	ReleaseArr(&arr);


	// 클래스를 이용한 방법
	// 초기화와 메모리 해제를 알아서 해주기 때문에 코드가 훨씬 간결해졌다.

	CArr carr;
	carr.push_back(10);
	carr.push_back(20);
	carr.push_back(30);

	return 0;

}

 

이번에는 클래스에서도 배열처럼 인덱스를 사용해 특정 데이터에 도달할 수 있도록 하는 기능을 직접 만들어보자.

구현하고자 하는 연산은 다음과 같다.

carr[1];

 

이것을 직접 구현해보자.

 

<CArr.h>

#pragma once
class CArr
{
private:
	int* m_pInt;
	int  m_iCount;
	int  m_iMaxCount;


public:
	void push_back(int _Data);
	void resize(int _iResizeCount);
    
    int operator[] (int idx);


public:
	CArr();
	~CArr();

};

 

<CArr.cpp>

int CArr::operator[](int idx)
{
    return m_pInt[idx];
}

 

이제 구현한 내용을 적용해보자.

주의할 점은 인덱스 [] 연산은 가능해졌지만 이를 이용해서 특정 인덱스 주소에 값을 집어넣는 것은 불가능하다.

 

<main.cpp>

int iData = carr[1];

// carr[1] = 200;  // 함수를 반환할 때는 함수가 종료될 때 
                   // 반환값은 임시적으로 저장하고 있던 곳에서 꺼내 온다.
                   // 따라서 이 임시 공간에 넣어 두고 복사본을 받아온 상태에서는
                   // 임시 공간이므로 이를 수정할 수도 없을 뿐더러
                   // 현재 반환 타입이 그냥 int이기 때문에 진짜 내가 원하는 곳에 수정할 수 없다.

 

이를 가능하게 하기 위해서는 다음과 같이 코드를 수정해야 한다.

레퍼런스를 활용해 반환 시키고자 하는 변수의 참조를 전달시킴으로서 바닥 안쪽에서는 반환 타입이지만, 그것이 똑같이 반환 할때 값과 동일시 되기 때문에 바로 직접적인 수정이 가능해지는 것이다.

 

<CArr.cpp>

int& CArr::operator[](int idx)
{
    return m_pInt[idx];
}

 

<main.cpp>

carr[1] = 100;

 

이제 클래스가 좀더 배열과 비슷한 형태가 되었다.

데이터를 마음대로 집어넣을 수 있고, 기본 문법에서 제공하는 배열처럼 실제 주소 연산은 아니지만 오퍼레이터 함수 클래스에서 구현해 놓은 것을 마치 기본 연산자처럼 주소 연산을 통해 넣을 수 있으며, 인덱스를 이용해 데이터에 접근 하는 모양과 유사하게 작동하도록 구현하였다.

'C,C++' 카테고리의 다른 글

namespace, 입출력 구현 (cin, cout)  (0) 2023.02.01
함수 템플릿, 클래스 템플릿, 클래스 템플릿 리스트  (0) 2023.01.31
클래스  (0) 2023.01.28
함수 포인터  (0) 2023.01.27
리스트  (0) 2023.01.26
카테고리
작성일
2023. 1. 28. 17:25
작성자
risehyun

이전에 배웠던 구조체처럼 클래스 역시 내가 만든 자료형이라고 할 수 있다.

그러나 C가 C++로 넘어가면서, 클래스에서는 접근 제한 지정자라는 것을 사용할 수 있게 되었다.

 

  • 접근 제한 지정자

private, protected, public으로 나뉜 접근 제한 지정자는 그 종류에 따라 해당 클래스에 접근할 수 있는 권한을 제한한다.

 

  • 생성자와 소멸자

또한 기존의 구조체가 객체를 하나 만들어내면 그 객체를 초기화해 주는 함수를 개발자가 직접 호출해주어야 했던 것과 달리, 클래스는 생성자라는 초기화 함수를 객체 생성 시 자동으로 호출하여 사용자가 초기화 관련 실수를 하지 않도록 해준다.

그러나 이러한 생성자는 엄연히 말하자면 여러 단계를 거친다는 점에서 정석적인 초기화 방법이 아니다.

이를 보완하기 위해 사용하는 것이 이니셜라이져라는 초기화 문법이다.

아래의 코드를 통해 지금까지 설명한 내용을 확인해 보자.

 

// <main.cpp>

#include "Arr.h"

struct tMy
{

};

class CMy
{
    // 접근 제한 지정자
    // private, protected, public
    
    private:
        int m_i;
        float m_f;
        
    public:
        void SetInt(int i)
        {
            m_i = i;
        }
        
   public:
      // 생성자
      CMy()          // 객체가 만들어짐
           : m_i(100)   // 이니셜라이져 (:) -> 초기화 문법
           , m_f(0.f)   // 초기화 할 변수가 여러 개 일때는 (,)를 써서 이어간다.
      {
          m_i = 100; // 만들어진 객체 공간에 접근해서 변수를 할당한다.
                     // 이러한 생성자는 객체가 만들어질 때 자동으로 호출된다.
                     // 생성자는 엄연히 말해서 초기화라고 할 수 없다.
                     // 진짜 초기화라면 호출과 동시에 값이 생겨야하는데,
                     // 이 경우는 1. 객체 생성 2. 변수 할당 2단계로 나눠져 있기 때문이다.
                     // 이러한 문제를 해결하기 위해 사용하는 것이 바로
                     // 이니셜라이져라는 초기화 문법이다.
          
      
      }

};

int maun()
{

    CMy c;
    c.SetInt(10);

    return 0;

}

 

이전에 가변 배열에 대해서 학습할 때, 데이터를 해제하는 함수를 다룬 적이 있다.

이때 해제 함수는 호출 시 해당 호출 주소로 들어온 객체 안의 멤버를 free() 함수로 메모리 해제하는 역할을 했었다.

 

이와 같이 객체가 사라질 때 꼭 해야만 하는 어떤 일들이 있을 수도 있다.

그런 것들을 개발자가 호출하는 것을 잊어버리면 문제가 발생할 여지가 있다.

그래서 C++에서는 객체가 없어질 때 자동으로 이러한 기능들을 호출되게 해주는 소멸자(~)라는 문법이 추가되었다.

 

~CMY() // 소멸자 : ~ 기호 뒤에 해당 클래스 이름과 동일하게 적어주면 된다.
{


}

 

현재 앞서 살펴본 main.cpp의 main 함수에서는 CMy라는 클래스를 c라는 지역 변수로 선언하였다.

따라서 이 c라는 지역 변수는 main 함수가 종료될 때 메모리가 해제된다.

이는 디스어셈블리로 확인해 보면 더욱 명확하다.

 

CMy를 각각 c1, c2, c3으로 선언하자 소멸자가 3번 호출됨을 알 수 있다.

컴파일러가 코드를 분석해서 자동으로 소멸자를 호출했다는 것을 알 수 있다.

 

  • 디폴트 생성자와 디폴트 소멸자

혹시 생성자와 소멸자를 입력하는 것을 깜빡하고 별도로 선언해주지 않더라도 컴파일러는 자동으로 이것을 만들어서 호출해 준다. 즉 코드 상에선 보이지 않지만 컴파일 과정에서 이것들이 만들어진다. 문법 규칙상 객체를 만들어 노출시키기 위해서는 생성자와 소멸자가 반드시 존재해야 하기 때문이다. 

 

대신 이렇게 자동으로 컴파일러가 만들어준 생성자와 소멸자는 구색상 존재 해야 하기 때문에 강제로 만들어진 것이므로  아무런 기능이 없기 때문에, 초기화되지 않으며 소멸자 역시 아무 일도 하지 않는다. 

이러한 자동 생성 생성자와 소멸자를 디폴트 생성자, 디폴트 소멸자라고 한다.

 

  • 멤버 함수
    해당 클래스가 사용하는 전용 함수를 멤버 함수라고 한다.
    이를 호출하기 위해서는 해당 클래스의 객체가 필요하다.

    만약 멤버 함수가 아닌 전역 함수로 선언하면 그 함수가 작동하기 위해서는 객체를 알아야 하고, 객체가 지정한 값을 알아야 한다. 모든 객체가 사용 가능해야만 하기 때문이다.

    반면 멤버 함수는 호출시킨 객체가 명확하기 때문에 눈에는 보이지 않지만 함수를 호출시킨 객체의 주소가 함께 존재하게 된다. 이를 멤버 함수에서는 this 키워드로 나타낸다.

    즉, 멤버 함수를 객체를 통해서 호출하면 해당 객체의 주소가 this 포인터로 전달된다.

class CMy
{
public:
    void SetInt(int i)
    {
        this->m_i = i;
    }
}

 

this 키워드는 해당 멤버 변수를 호출한 객체의 주소 타입을 이야기한다.

이를 기존에 배웠던 C 스타일로 구현하려면 다음과 같았을 것이다.

public :
    void SetInt(CMy* _this, int i)
    {
        _this->m_i = i;
    }

 

하지만 이것을 일일이 구현하는 것이 불편하기 때문에, 이를 감춰두고 대신 this 키워드를 사용하는 것이다.
함수가 호출될 때 this라는 자리에 전달받기 때문에 위에서 살펴본 코드를 다시 보면, 기존의 자리를 this가 대신하고 있다.

 

class CMy
{
public:
    void SetInt(int i)
    {
        this->m_i = i;
    }
}

디버그 모드로 로컬()을 확인해 보면 아래와 같이 this가 인자로 받는 것이 CMy* 형식임을 알 수 있다.

 로컬은 현재 호출된 함수의 지역 변수들을 보여주는 곳이다.

 

하지만 멤버 함수에서는 지칭하는 멤버가 당연히 이것을 호출한 객체일 것이라는 것이 명확하기 때문에 아래 코드처럼 this 키워드마저도 생략할 수 있다.

public :
    void SetInt(int i)
    {
        m_i = i;
    }

 

  • 클래스끼리의 대입
    이번에는 클래스끼리의 대입에 대해서 알아보자. 아래와 같이 처리된 코드가 있다고 가정한다.

CMy c;
c.SetInt(10);

CMy c2;
c.SetInt(100);

CMy c3;
c.SetInt(1000);

c3 = c2;

 

마지막 코드인 c3 = c2; 를 실행하는 것은 일반 변수에서 다음과 같은 처리를 하는 것과 같다.

 

int i = 0;
int a = 10;

i = a; // i에 10이 할당된다

 

int의 경우 이미 데이터의 크기와 형식이 정해져 있기 때문에 위와 같은 코드를 실행했을 때 i에 10이 할당되는 것은 매우 자연스럽다. 하지만, 클래스 변수인 c3에 c2의 값을 할당하는 일은 어떻게 가능한 걸까?

바로 대입 연산자 때문이다. 이 대입 연산자는 생성자를 만들지 않아도 컴파일러에서 디폴트 생성자를 자동으로 만들어주었던 것처럼, 자동으로 만들어지는 것 중 하나이다.

  • 대입 연산자
class CMy
{
    private:
        int m_i;
        float m_f;
        
    public:
        void SetInt(int i)
        {
            m_i = i;
        }
        
        // 대입 연산자
        const CMy& operator =(const CMy& _Other)
        {
             m_i = _Ohter.m_i;
             m_f = _Ohter.m_f;
             
             return *this;
        }
        
   public:
      CMy()
           : m_i(100)
           , m_f(0.f)
      {
          m_i = 100; 
      }

};

 

상기 코드 중 대입 연산자 부분은 사용자가 입력하지 않아도 자동으로 생성되는 내부 코드를 적은 것이다.

이런 코드가 어떻게 자동으로 생성될 수 있는 걸까?


각 클래스의 객체끼리 멤버의 합산을 하려는 상황을 가정해 보자.

단순히 더하는 연산만 생각했을 때는 다음과 같은 코드를 생각해 볼 수 있을 것이다.

 

CMy c;
CMy c2;
CMy c3;

c3 + c2;

 

그렇다면 이 연산이 발생했을 때 호출될 함수가 있어야 하지 않을까?

(+)와 같은 연산자도 특정 기능을 하는 일종의 함수라고 생각한다면 클래스 멤버끼리의 합산을 하는 기능 역시

사용자가 원하는 별도의 기능을 가지고 있으므로 이러한 연산을 하는 함수를 직접 만들어야 한다는 뜻이다.

 

c3 = c2;

 

하지만 대입 연산자 같은 경우는 사용자가 만들지 않아도 클래스에 대해서 자동으로 만들어 주고 있었기 때문에 바로 적용이 가능하다. 대입 연산자 외의 연산자들은 사용자가 직접 만들어야 한다는 뜻이다. 그럼 대입 연산자를 사용했을 때 자동으로 해당 기능 함수가 호출된 것처럼 될 것이다.

 

이러한 기능을 클래스에서는 연산자 오버로딩이라고 부른다.

 

이어서 내용을 학습하기 전에 잠시 헷갈리기 쉬운 포인터 관련 연산과 이번에 새롭게 배울 레퍼런스에 대해 짚고 넘어가자.

자료형 * 변수명 : 자료형 포인터 타입 변수 선언
*포인터변수 : 포인터 변수 안의 주소를 역참조
&변수; : 본인의 주소 값을 반환
자료형 & 변수명; : 레퍼런스(참조) 변수 선언 -> 원본에 접근할 수 있는 방법 중 하나로 C++에서 새롭게 추가됨
  • 레퍼런스
    레퍼런스의 기능은 아래의 코드와 비슷하다고 볼 수 있다.
int a = 10;   // a라는 변수가 있다.
int* p = &a;  // 이 a 변수의 주소를 p라는 int형 포인터에 받아온다.
*p = 100;     // p에 담긴 주소를 역참조하여 수정한다.

 

위의 코드를 레퍼런스를 사용해 비슷하게 나타내면 다음과 같다.

int& iRef = a; // iRef라는 변수가 a를 참조한다.
iRef = 100;    // a가 참조된 상태인 iRef의 값을 바꾼다.

 

a라는 변수의 주소를 받아서 주소로 접근을 해서 거기를 수정한다는 뜻이기 때문에 이는 포인터와 다를 게 없다.

어떻게 이렇게 작동하는 것이 가능할까?

레퍼런스 사용 코드를 어셈블리 코드로 확인해 보면 포인터와 동일하게 작동함을 알 수 있다.

즉, 레퍼런스와 포인터는 컴파일러 입장에서는 똑같은 기능을 한다.

 

그렇다면 왜 포인터가 아닌 레퍼런스를 쓰는 걸까?
컴파일러 입장에서 컴파일된 코드는 결국 똑같아도 사용자는 이를 C++ 문법으로 사용한다.

그렇기 때문에 사용자 입장에서는 호출 방식 자체가 분명히 다르다.

 

레퍼런스는 포인터에 const가 붙은 경우와 비교하면 쉽게 이해할 수 있다.

 

int a = 10;  
int* const p = &a; 
*p = 100;

 

위의 코드처럼 const가 수식하는 부분이 변수명이면 그것은 이 포인터 변수 자체를 상수화 하겠다는 의미이다.

이렇게 되면 이 포인터 변수는 더 이상 a라는 변수의 주소를 제외한 다른 변수의 주소를 받아올 수 없게 된다.

 

레퍼런스도 이와 마찬가지로 선언과 동시에 이 레퍼런스 변수가 특정 변수를 참조하게 되면 그 뒤로는 다른 변수를 참조하려고 해도 그것은 곧 현재 참조된 변수 값을 수정하려는 것으로 받아들여질 뿐 다른 변수를 참조할 수는 없게 된다.

즉, int& iRet = a; 코드는 'iRet과 a는 지금부터 동일 취급이 된다'는 것이다.

 

그렇기 때문에 '나(iRef)를 100으로 수정한다는 것은 (iRef= = 100;) 너(a)를 수정하는 것과 같다'라고 하는 것이다.

 

이렇게 레퍼런스를 썼을 경우의 장점은 (포인터와 똑같기 때문에 장점이라고 하기에는 애매하지만) 다음과 같다.

 

1. 한번 참조를 받으면 다른 것으로 바꿀 수 없기 때문에 주소 연산의 실수를 할 이유가 없다

2. 레퍼런스는 원본을 수정할 때 역참조에 해당하는 포인터 연산을 할 필요가 없어 원본을 바로 바꿀 수 있다.

 

2번째 장점 때문에 레퍼런스 밖에 할 수 없는 부분들이 존재하기도 한다.

 

매번 포인터로 역참조 연산을 명시해 줘야 원본으로 접근할 수 있는 포인터 변수와 달리 본인이 참조받자마자 바로 자기가 그 변수를 즉시 수정할 수 있는 형태이기 때문에 나중에 원하는 형태의 함수를 설계하는 데 있어서 중요한 점이 있다.

그러므로 호출 방식 자체의 형태의 변화가 레퍼런스의 중요한 의미 중 하나다.

 

또한 주소값을 즉시 얻어와서 쓰는 경우와 달리, 레퍼런스는 주소값을 바꿀 순 없지만 이 안에 참조받은 특정 주소값이 들어 있다는 것이 명확하다. 이렇듯 확실히 값을 보유하고 있지만 주소 개념이 문법적으로 보이지 않는다는 특성 때문에

기존의 포인터가 가진 문제점 중 하나였던, 주소로 바로 접근을 해서 안에 내용을 변경시킬 수 있는 주소 관련 실수의 위험요소가 없다. 따라서 원래 의도했던 것보다 더 오버해서 잘못된 주소로 접근하는 등의 일은 없어지는 것이다.

 

  • 레퍼런스를 통해 참조를 받았지만 원본은 수정할 수 없게 하고 싶은 경우

    이번에는 특정 변수를 레퍼런스를 사용해 참조 했으나, 그 원본이 아래 코드의 const 포인터처럼 수정되지 않고 언제나 하나의 값을 유지하도록 하고 싶은 경우에는 어떻게 구현해야 하는지에 대해 알아보자.
// const 포인터를 사용한다면 아래처럼 할당된 주소 안의 데이터 값을 수정하는 코드가 성립되지 않는다.

const int* p2 = &a;
//*p2 = 10;            // 값을 수정하는 것이 불가능하므로 잘못된 코드이다.

 

레퍼런스 변수도 const 레퍼런스를 하면 위와 동일하게 수정이 불가능하다.

 

const int& iRefConst = a;
//iRefConst = 10;

 

const 레퍼런스는 예시 코드로 작성한 포인터 식으로 치차면 const int* const p2 = &a; 와 같다.

레퍼런스 자체가 처음 한 번만 유일한 주소를 참조받을 수 있는 특성을 가지고 있기 때문에

거기에 앞쪽까지 const를 붙이면 '한 번 유일한 주소를 참조하고 나면 그 주소에 있는 값도 수정할 수 없는 포인터'

거의 유사하게 작동하게 된다.

 

포인터의 경우는 const가 무엇을 수식하냐에 따라 작동하는 방식이 2가지 존재했지만 레퍼런스의 경우는 그런 방식을 나눌 필요 없이 오직 하나의 경우로만 작동한다는 것이다.

 

  • const 레퍼런스를 사용하는 이유
    const 포인터를 사용하는 이유와 동일하게, const 레퍼런스는 원본을 전달하는 비용을 적게 하고 싶은 경우에 사용하면 유용하다. 전달해야 할 원본의 크기가 굉장히 큰 경우, 복사 비용이 부담이 된다. 이 때 포인터 변수는 항상 주소를 전달해 줄 때 고정 사이즈를 알기 때문에 주소를 통해서 접근할 수 있다.

    레퍼런스도 비슷하게 원본 자체에 대해서 참조를 주가 때문에 원본 전체를 복사하는 비용을 줄일 수 있다.
    따라서 바닥 안쪽에서도 참조 변수로 받아온 것을 수정하면 곧 그게 원본을 수정하게 되는 것이다.

    하지만 이때  상대쪽에서 값을 읽어볼 수 있게 참조로 주소 값을 전달하되, 원본이 수정되기는 원치 않은 경우 const 포인터를 써서 인자를 받는 것처럼 const 레퍼런스도 동일하게 사용할 수 있다.


  • 연산자 오버로딩의 규칙
    이번에는 앞서 배웠던 대입 연산자를 통해 연산자 오버로딩의 규칙을 알아보겠다.

class CMy
{
    private:
        int m_i;
        float m_f;
        
    public:
        void SetInt(int i)
        {
            m_i = i;
        }
        
        // 대입 연산자
        const CMy& operator =(const CMy& _Other)
        {
             m_i = _Ohter.m_i;
             m_f = _Ohter.m_f;
             
             return *this;
        }


대입 연산자 부분을 다시 살펴보니 넘겨받는 인자를 레퍼런스로 참조하는 것을 확인할 수 있다. (const CMy& _other)

이는 이 (=) 연산자 함수를 호출한 객체가 인자로 들어오는 것을 의미한다.

 

그러면 다시 아래의 대입 연산자 코드를 살펴보자.

c3 = c2;

 

c3가 대입 연산자를 호출한 객체이다.

멤버 함수들의 경우에는 c3.SetInt(1000); 처럼 (.)을 이용해 호출했지만 오퍼레이터 함수의 멤버 함수들은 연산자가 호출 됐을 때 함께 호출된다. 그렇기 때문에 여기서 this 안에는 c3의 객체 주소가 담겨져 전달이 되고, 대입 연산자에서 선언한 인자 안에는 c2가 들어가게 되는 것이다.

내부적인 작동 모습을 확인하기 위해 더 자세히 코드를 작성하면 다음과 같을 것이다.

        // 대입 연산자
        const CMy& operator =(const CMy& _Other)
        {
             this->m_i = _Ohter.m_i;
             this->m_f = _Ohter.m_f;
             
             return *this;
        }

대입 연산자 호출과 함께 this에는 c3의 객체 주소가 담긴 채로 전달되고, _other 인자에는 c2 객체를 참조 받게 된다.
그런데 이 상황에서 c2의 원본이 수정될 일은 없으므로 참조를 할 때 const 레퍼런스를 사용해서 받아온 것이다.

이때 c2가 추후에 다시 함수를 호출시켜 c3가 인자로 들어오는 것과 같이 연쇄적인 상황이 있을 수 있으므로

반환 타입으로 *this를 넣어 그 원본(여기선 c2)을 참조로 리턴해주게끔 하였다.

 

 

그럼 지금까지의 내용을 정리해 보자.

<C++ 클래스 특징>
1. 접근제한 지정자
2. 클래스 내의 멤버변수 or 멤버함수의 접근 레벨

<생성자, 소멸자>
1. 객체 생성, 소멸 시 자동으로 호출
2. 직접 만들지 않으면 기본 생성자, 기본 소멸자가 만들어짐

<멤버함수>
1. 해당 클래스가 사용하는 전용 함수
2. 해당 클래스의 객체가 필요함
3. 멤버함수를 객체를 통해서 호출하면, 해당 객채의 주소가 this 포인터로 전달된다.

<포인터 연산과 레퍼런스>
자료형 * 변수명 : 자료형 포인터 타입 변수 선언
*포인터변수 : 포인터 변수 안의 주소를 역참조
&변수; : 본인의 주소 값을 반환
자료형& 변수명; : 레퍼런스(참조) 변수 선언

<레퍼런스>
원본에 접근할 수 있는 방법 중 하나로 C++에서 새롭게 추가된 기능이다.
포인터와 유사하지만 포인터 변수가 '원본의 주소를 가져온다'라고 표현된다면
레퍼런스는 '원본을 참조한다'라고 표현할 수 있다.

레퍼런스 변수를 선언하면 원본과 할당된 변수가 동일하게 취급된다.
이는 const 포인터와 비슷한 역할을 한다.

레퍼런스가 포인터와 다른 점은, 기존의 포인터가 역참조 연산을 필요로 한 것과 달리
레퍼런스는 바로 참조받은 변수를 통해서 직접적으로 원본을 수정할 수 있다.
따라서 주소를 잘못 다루면서 파생되는 문제의 여지가 없다.

<const 레퍼런스의 활용>
const 포인터처럼 const 레퍼런스는 원본을 전달하는 비용을 적게 하고 싶은 경우에 사용할 수 있다.

 

 

'C,C++' 카테고리의 다른 글

함수 템플릿, 클래스 템플릿, 클래스 템플릿 리스트  (0) 2023.01.31
클래스를 이용한 배열  (0) 2023.01.31
함수 포인터  (0) 2023.01.27
리스트  (0) 2023.01.26
가변 배열  (0) 2023.01.24
카테고리
작성일
2023. 1. 27. 06:53
작성자
risehyun

 

이전에 배웠던 변수의 주소 뿐만 아니라 함수의 주소도 가져올 수 있다.

함수의 주소를 함수 포인터라고 한다.

함수는 이름 자체가 주소이기 때문에 인자로 넘길 때는 함수 이름만 쓰거나, 기존 방식대로 &함수이름 형식으로 써도 된다.

 

이를 활용하는 방법을 생각해보자.

지난 시간에 구현한 Sort() 함수에서 사용자가 어떤 정렬을 선택할 수 있을지 고를 수 있도록 해보려고한다.

내가 만든 정렬이 버블 정렬이라고 하면, 그 방식으로 정렬을 해 준다.

또 퀵 소트를 선택하면 그 방식으로 정렬해 준다.

 

이런식으로 함수를 전달하면 또 전달하는 방법이 있다고 가정하면 만든 기능을 인자로 전달할 수 있으므로

전달 받은 인자를 호출한 기능에서 사용하여 내부 데이터를 정렬하게 된다.

 

이를 위해 Sort() 함수를 다음과 같이 변경할 수 있다.

void Sort((정렬을 당할 가변배열 객체 하나, 어떤 방식의 정렬을 써줄지 정렬 함수 기능을 선택한 걸 받는 객체 하나));

제대로 작성하면 다음과 같다.

void Sort(tArr* _pArr, void(*SortFunc)(int*, int));
// 시작 주소, 인트포인터, 인트 카운트

 

함수의 주소를 받기 위해서는 다음과 같이 작성하면 된다.

void Test()
{

}


int main()
{
	void(*pFunc)(void) = nullptr; // 함수 주소를 받는 포인터 선언
	pFunc = Test; // 실제 함수 이름을 적어서 주소를 받음

	pFunc(); // Test 함수롤 호출함

    return 0;
}

 

기능에 맞춰서 내용을 다듬으면 다음과 같다.

<Arr.h>

// 메모리 정렬 함수
void Sort(tArr* _pArr, void(*SortFunc)(int*, int));

 

<Arr.cpp>

void Sort(tArr* _pArr, void(*SortFunc)(int*, int))
{
	SortFunc(_pArr->pInt, _pArr->iCount);
}

 

<Main.cpp>

Sort(&s1, &BubbleSort);

 

이렇게 하면 구현한 정렬 기능을 테스트 할 때 인자로 넘기는 함수명만 바꿔주면 그대로 실행되어 더욱 편리해진다.

 

  • 함수 포인터는 주로 언제 쓰일까?
    1. 내가 만든 어떤 기능을 다른 사람들에게 주고자 하는데,
    기능 자체가 다른 사람들이 만든 기능을 가져다 쓰는 형태일 경우 활용한다.

    ex. 게임에서 어떤 애니메이션 기능을 만들었을 때 이 기능을 다른사람들이 써서 캐릭터 애니메이션을 만들고 움직이게 하려고 한다. 그때 다른 작업자가 애니메이션 동작이 끝난 뒤에 자기가 만든 어떤 특정 함수가 한번씩 호출되었으면 좋겠다고 할때, 기능을 제공하는 사람으로서 함수를 제공하려면 완성된 형태로 제공해야 한다.

    하지만 다른 사람이 바닥 안쪽에 만든 함수를 어떻게 내가 미리 구현할 수 있을까? 심지어 내가 지금 만든 기능을 앞으로 몇 명의 작업자들이 나중에 추가로 사용할지 알 수 없는데도 말이다. 

    이럴 때는 콜백 같은 기능을 만들어서 작업이 끝나고 그 작업자가 함수를 주면 자신이 호출해주도록 하면 된다.
    이쪽에서는 그 함수 포인터 안에 어떤 기능이 들어와있으면 호출하고, 널 포인터면 호출하지 않게끔 구현하는 것이다.

     2. 분기가 매번 검사되는 경우
    예를 들어, A를 하면 이 함수를, B하면 이 함수를 호출하는 등 분기가 막 생겨 상황에 따라 호출되는 함수가 달라질 경우 여기서 IF문을 쓰면 이 상황이 발생할 때마다 체크를 해줘야 한다. 하지만 함수 포인터를 쓰면 함수가 알맞게 딱 한번 세팅되면 그것만 계속 호출하면 되므로 조건을 계속해서 확인할 필요가 없어진다. 도중에 상태가 바뀌더라도 바뀐 상태에 맞는 함수를 호출해야 할 함수로 가리키면 또 바뀐 함수만 호출하므로 간편하게 변경할 수 있게 된다. 결과적으로 코드가 깔끔해지는 것이다.

'C,C++' 카테고리의 다른 글

클래스를 이용한 배열  (0) 2023.01.31
클래스  (0) 2023.01.28
리스트  (0) 2023.01.26
가변 배열  (0) 2023.01.24
동적할당  (0) 2023.01.23
카테고리
작성일
2023. 1. 26. 20:29
작성자
risehyun

 

지난 시간에 배운 가변 배열과 마찬가지로 리스트도 데이터를 계속해서 추가할 수 있다. 그런데 데이터로 관리하는 방식이 가변 배열과 조금 다르다.

 

  • 리스트와 가변 배열의 차이점

리스트에 배열이라는 이름이 붙지 않은 이유는 다음과 같다.

가변 배열의 특징은 힙 메모리에 메모리를 연속적으로 잡는다는 점이다.

그러나 이와 달리 리스트는 데이터 공간이 모자라면 그때마다 새로 할당하는 방식으로 작동한다.

할당된 최종 공간 기준으로 봤을 때 저장하고자 하는 데이터가 연속된 메모리로 힙 메모리 상에 일렬로 존재한다.

또한 포인터를 통해 인덱스에 접근이 가능하다. 기본 형태 자체는 기본 문법에서 제공하는 일반 배열 Array과 같아 보인다.

길이는 계속 확장될 수 있는 구조지만 힙 메모리 공간을 잡아 마치 배열처럼 쓸 수 있는 것이다.

 

  • 그렇다면 링크드 리스트는 어떤 특징을 가지고 있는가?

링크드 리스트에는 데이터가 같은 힙 메모리 영역을 사용하지만 메모리가 띄엄띄엄 끊어서 저장되어 있다.

데이터가 모자를 때마다 이렇게 띄엄띄엄 메모리들이 하나씩 증가하게 된다.

이와 같이 데이터가 서로 쪼개져있기 때문에, 특정 인덱스(EX.3) 데이터에 접근하기 위해서

데이터를 저장할 때 그 데이터에는 그다음 인덱스에 있는 데이터의 주소를 함께 넣는다.

 

이렇게 매번 동적할당을 할 때 다음 데이터의 주소를 알게 함으로써 서로가 서로를 연결하고 있기 때문에

순차적으로 데이터 주소를 따라가면 원하는 특정 인덱스에 있는 데이터에 접근할 수 있다.

 

이때 데이터 하나를 저장하는 하나의 단위를 노드(Node, 마디라고도 한다)라고 부른다.

 

  • 리스트의 구현
    <LinkedList.h>
typedef struct _tagNode
{
    int    iData;
    struct _tagNode* pNextNode;

}tNode;

typedef struct _tagList
{
    tNode* pHeadNode;
    int    iCount;
}tLinkedList;

// 연결형 리스트 초기화
void InitList(tLinkedList* _pList);

// 연결형 리스트에 데이터 추가
void PushBack(tLinkedList* _pList, int _iData);

// 연결형 리스트 메모리 해제
void ReleaseList(tLinkedList* _pList);

 

연결형 리스트에서 필요한 기능은 초기화, 데이터 추가, 메모리 해제로 총 3가지이다.

 

  • 연결형 리스트 초기화
// <LinkedList.cpp>
void InitList(tLinkedList* _pList)
{
	// 리스트는 미리 데이터를 사용할 공간 만큼 메모리를 할당해야하는 가변 배열과 달리
	// 데이터가 들어올 때마다 그때 그때 만들어지므로 미리 노드를 만들 필요가 없다.
	// 따라서 아래와 같이 노드를 nullptr로 초기화한다.
	_pList->pHeadNode = nullptr;

	// 아직 데이터가 아무것도 들어와있지 않으므로 Count도 0으로 초기화한다.
	_pList->iCount = 0;


}

 

  • 연결형 리스트에 데이터 추가
void PushBack(tLinkedList* _pList, int _iData)
{
	tNode* pNode = (tNode*)malloc(sizeof(tNode));
	pNode->iData = _iData;
	pNode->pNextNode = nullptr;

	// 추가한 데이터가 처음인지 아닌지 체크 (head 적용을 위해서)
	// 처음인지 체크하려면
	// 1. pHeadNode = nullptr인 경우 head가 비어있다 -> 첫 데이터다
	// 2. iCount == 0인 경우 -> 아직 할당된 데이터가 없다는 뜻 -> 첫 데이터다
    // 여기서는 iCount로 해봄

	if (0 == _pList->iCount)
	{
		_pList->pHeadNode = pNode;
	
	}
	else // 데이터가 1개 이상 있다
	{
		// 현재 가장 마지막 노드를 찾아서,
		// 해당 노드의 pNext를 생성시킨 노드의 주소로 채운다.

		tNode* pCurFinalNode = _pList->pHeadNode;
		while (pCurFinalNode->pNextNode)
		{
			pCurFinalNode = pCurFinalNode->pNextNode;
		}

		pCurFinalNode->pNextNode = pNode;
	}

	++_pList->iCount;
}

이 함수를 사용해 데이터를 추가하면 아래와 같이 순차적으로 각 노드가 연결된 형태로 저장된다.

마지막 300이 저장된 노드에는 다음 노드가 없기 때문에 pNextNode가 NULL이 된다.

 

  • 연결형 리스트에 메모리 해제하기
    만약 연결형 리스트가 벡터처럼 현재 할당된 곳 기준으로 연속된 공간으로 저장되어 있다면,
    그 주소를 선택해서 한 번에 메모리를 해제할 수 있겠지만 리스트는 각 노드가 조각조각 잘려있는 형태로
    따로 따로 저장되어 있으므로 시작 주소를 알아야 하고,
    그 주소를 기준으로 하나씩 순서대로 따라가면서 다음 노드를 찾아야 한다.

    이 기능을 구현하기 위해서는 재귀함수를 사용하거나 반복문을 사용하는 방법이 있다.

    1. 재귀함수를 사용하는 경우
// 재귀함수 버전 삭제
void Release(tNode* _pNode)
{
	if (nullptr == _pNode)
		return;

	Release(_pNode->pNextNode);

	free(_pNode);
}

 

그러나 재귀함수를 사용하는 경우 그 안에 들어있는 데이터 개수가 많으면
똑같은 함수가 데이터 개수만큼 호출이 되므로 비효율적이다.
특히 데이터의 개수가 많을 수록 더욱 이런 현상이 심화된다.
따라서 재귀함수 대신 아래와 같이 반복문을 사용하는 것이 좋다.

 

// 반복문을 사용한 데이터 해제
void ReleaseList(tLinkedList* _pList)
{

	// Release(_pList->pHeadNode);

	tNode* pDeletNode = _pList->pHeadNode;

	// 차례대로 하나씩 데이터를 삭제해 나간다.
	while (pDeletNode)
	{
		tNode* pNext = pDeletNode->pNextNode;
		free(pDeletNode);
		pDeletNode = pNext;
	}
}

 

실행해보면 100 -> 200 -> 300 순서로 데이터가 순차 해제 된다.

완전히 해제가 끝나면 아래와 같이 모든 노드 안의 값이 사라지며 노드 간의 연결도 해제된다. 

 

  • 리스트의 데이터 추가를 위한 PushFront 함수 구현해보기
    앞서 구현한 PushBack() 함수는 데이터가 추가되면 이전에 있던 노드의 뒤에 새로운 노드를 만들고 둘을 연결시키는 방식을 사용하였다. 이번에는 그와 반대로 새로운 데이터 추가시 이전 노드의 뒤가 아닌 앞에 추가되는 함수를 구현해보자.

    이 함수를 구현하기 위해서는 다음과 같은 기능들이 필요할 것이다.

    1. 새로 생성시킨 노드의 다음을 기존의 Head로 지정하는 것
    2. 리스트의 Head 노드 포인터를 갱신하여 각 노드의 주소끼리 연결 시켜주는 것
    3. 데이터가 증가했으므로 데이터 카운트를 증가하여 갱신시킨다.

    이를 코드로 구현하면 다음과 같다.
void PushFront(tLinkedList* _pList, int _iData)
{
	// 새로 생성시킨 노드의 다음을 기존의 헤드로 지정한다.
	tNode* pNewNode = (tNode*)malloc(sizeof(tNode));
	pNewNode->iData = _iData;
	pNewNode->pNextNode = _pList->pHeadNode;

	// 리스트의 헤드노드 포인터를 갱신한다.
	_pList->pHeadNode = pNewNode;

	// 데이터 카운트를 증가시킨다.
	++_pList->iCount;

}

나중에 할당된 데이터가 이전 데이터의 앞으로 저장된 것을 확인할 수 있다. 300 -> 200 -> 100 순서로 출력된다.

 

  • 연결형 리스트의 장점과 단점
    가변 배열과 달리 연결형 리스트의 경우 데이터의 추가, 삭제시 연결되어 있는 노드만 수정하면 되므로 데이터의 구성을 변경할 때 큰 자원 소비 없이 쉽게 해낼 수 있다. 만약 같은 PushFront 함수를 구현한다고 가정한다면 리스트는 이를 연결 Node의 수정으로 간단히 해결할 수 있는 반면에 가변 배열에서 똑같은 PushFront 함수를 구현한다면 하나의 데이터가 새로 들어올 때마다 모든 데이터를 한 칸씩 뒤로 옮겨주어야하므로 많은 시간과 자원이 소모되어 비효율적일 것이다. 

    반면 특정 인덱스에 접근해야하는 상황이 있다고 가정할 때, 일렬로 데이터가 저장되어 있는 가변 배열의 경우 바로 인덱스 값을 사용하여 특정 위치에 있는 값을 얻어 올 수 있다. 하지만 연결형 리스트의 경우 한 노드가 가지고 있는 정보는 데이터와 다음 노드의 주소 뿐이므로 특정 인덱스에 접근하려면 Head 노드부터 하나씩 노드를 거쳐가며 원하는 인덱스가 있는 곳까지 탐색을 진행해야만 한다. 이 경우 접근해야 하는 인덱스 값이 크다면 그 인덱스에 도달하기까지  O(n)의 아주 많은 시간이 소모될 것이다.

'C,C++' 카테고리의 다른 글

클래스  (0) 2023.01.28
함수 포인터  (0) 2023.01.27
가변 배열  (0) 2023.01.24
동적할당  (0) 2023.01.23
구조체 포인터  (0) 2023.01.20
카테고리
작성일
2023. 1. 24. 23:07
작성자
risehyun

지난 시간에 배웠던 동적 할당을 활용해 가변 배열을 만들어보자.

가변 배열이란 이전에 배웠던 크기가 고정되어 있는 일반 배열과 달리 크기에 변동이 생길 수 있는 배열이다.

따라서 아래처럼 지역변수를 사용할 수 없다.

 

int main()
{
int a = 100;

int iInput = 0;
scanf_s("%d", &a);

int arr[a] = {}; // 사용불가

return 0;
}

 

같은 맥락으로 구조체도 살펴보자. 구조체 안에는 사용자가 임의로 변수를 멤버로 넣을 수 있다.

하지만 역시 이 구조체 안에도 배열 개수를 선언할 때는 다음과 같이 변수를 사용한 동적 배열은 만들 수 없다. 

int g_i = 100;

typedef struct _tagST
{
	int iArr[g_i]; // 사용불가
}ST;

 

그렇다면 어떻게 해야 동적 할당이 가능한 가변 배열을 만들 수 있을까?

바로 힙 메모리 영역에 저장된 것을 사용하는 것이다.

이를 위해서 가변 배열이라는 어떤 형태를 선언하고 그걸 가변 배열의 동작을 하는 객체로 만드는 방법을 사용한다.

 

여기서 객체에 대해 생각해 보자.

객체란 int가 자료형이라면 int a가 바로 객체에 해당한다.

비유하자면 int라는 자료형(붕어빵틀)을 실제로 만들어낸 데이터(붕어빵)인 것이다.

 

이러한 내용을 바탕으로 가변 배열을 직접 만들어보기 위해 새로운 헤더 파일을 하나 생성해 본다.

 

<Arr.h>

typedef struct _tagArr
{

}tArr;

 

가변 배열이란 자료형으로서 역할을 하려면 어떤 멤버들을 가지고 있어야 할까?

앞서 언급한 것처럼 힙 메모리 영역에 저장되는 데이터를 멤버로 가져야 한다.

 

// <가변 배열의 구현>
typedef struct _tagArr
{
    int*	pArr;     // 데이터가 시작되는 지점을 알고 있어야 한다
    
    int		iCount;    // 다음 데이터가 어디부터 시작인지 알기 위해 현재까지
                      // 들어온 데이터가 몇 개인지 알아야 한다   
                
    
    int     iMaxCount; // 데이터가 최대 몇 개까지 들어갈 수 있는지 한계치를 알아야 한다
    
}tArr;

 

구현한 배열을 main 함수에서 실제로 사용하기 위해 초기화를 해보자.

int main()
{
    tArr s;
    s.pInt = (int*) malloc(40)
    s.iCount = 0;
    s.iMaxCount = 10; // 인트형이 40바이트 들어있으므로 10개
    
    
    return 0;

}

 

위의 초기화 내용은 자주 반복될 것이므로 초기화 함수를 만들어 사용하는 것이 효율적이다.

// <Arr.h>
typedef struct _tagArr
{
    int*	pInt;     // 데이터가 시작되는 지점을 알고 있어야 한다
    
    int		iCount;    // 다음 데이터가 어디부터 시작인지 알기 위해 현재까지
                      // 들어온 데이터가 몇 개인지 알아야 한다   
                
    
    int     iMaxCount; // 데이터가 최대 몇 개까지 들어갈 수 있는지 한계치를 알아야 한다
    
}tArr;

void InitArr(tArr* _pArr); // 헤더에 직접 함수를 모두 구현하면 복사붙여넣기 과정에서
                           // 중복되므로 선언만 해준다.

 

헤더에 선언한 함수를 Arr.cpp에 구현한다.

 

<Arr.cpp>

#include "Arr.h"

#include <iostream>

void InitArr(tArr* _pArr)
{
	// '->' 멤버접근
	
	_pArr->pInt = (int*)malloc(sizeof(int) * 2); // 미리 8 바이트만큼의 공간을 할당
	_pArr->iCount = 0;
	_pArr->iMaxCount = 10;

}

 

<main.cpp>

#include <iostream>

#include "Arr.h"

int main()
{
	tArr s = {}; // 메인 함수 종료시 배열 객체 메모리가 해제 됨.
                 // 이때 단순히 배열 객체만 해제되면 객체의 멤버가 가르키고 있던 실질적 데이터
                 // 저장 공간이었던 힙 메모리에는 해제를 해주지 않기 때문에 메모리 누수가 발생한다.
                 // 즉, 아래에서 InitArr()로 초기화를 진행하고 나면 나중에 사용 목적이 끝난 뒤
                 // 종료 직전에 힙 메모리 영역에 넣고 쓰고 있던 메모리를 해제해주어야 한다.
                
	InitArr(&s); 

	return 0;

}

 

 

힙 메모리 영역에 할당된 것을 해제해 주기 위해 배열 메모리 해제 함수와 데이터를 넣어서 실질적으로 활용해 볼 데이터 추가 함수를 추가한다.

 

<Arr.h>

// 배열 초기화 함수
void InitArr(tArr* _pArr);

// 데이터 추가 함수
void PushBack(tArr* _pArr, int _iData);

// 공간 추가 확장 (추후 구현)
void Reallocate(tArr* _pArr);
 
// 배열 메모리 해제 함수
void ReleaseArr(tArr* _pArr);

 

<Arr.cpp>

void PushBack(tArr* _pArr, int _iData)
{
	// 배열에 데이터를 추가하기 전에 먼저 현재 개수와 최대 개수가 같은지 체크해야한다.
	
	if (_pArr->iMaxCount <= _pArr->iCount) // 힙 영역에 할당한 공간이 다 찬 경우
	{
		// 재할당
		Reallocate(_pArr);

	}

	// 공간 문제가 없어 할당이 가능한 경우
	// 데이터 추가 (현재 주소의 첫번째 칸부터 데이터를 추가한다) 
	// 가변 배열 객체 안에 힙 메모리 주소 변수와 카운트를 동시에 세고 있다.
	// 그 다음에 들어갈 것은 가변 배열이 가진 주소 변수로 가서
	// 인덱스 접근을 할 건데, 그때의 인덱스가 본인이 가진 카운터 값이다.
	// 그 카운터 값을 가지고 주소에 접근해서 그 위치의 데이터를 수정하고 나면
	// 카운트 값이 증가해야 개수가 1 증가하면서 
	// 또 다음 번에 그걸 그대로 맞물려 돌아간다.
	// 따라서 후위 연산자를 사용해 입력까지 다 끝나고 나서 카운터 값이 증가하도록 한다.
	_pArr->pInt[_pArr->iCount++] = _iData;

}

void ReleaseArr(tArr* _pArr)
{
	free(_pArr->pInt); // 할당한 메모리를 해제함
	_pArr->iCount = 0; // 
	_pArr->iMaxCount = 0;
}

 

메모리를 직접 할당하는 동적 할당 기능이 필요하기 때문에 가변 배열을 구현할 땐 유의할 사항이 많다.

실수로 크기를 원래 하려고 했던 것보다 크게 잡거나, 할당하지 않은 곳에 접근해서

메모리를 수정하거나 써 버리는 경우 심각한 문제가 발생할 수 있다.

힙 손상이라고도 하는 이러한 오류는 실행 중에는 발견되지 않다가 뒤늦게 발견될 수 있기 때문에

문제가 발생한 원인을 쉽게 알 수 없어 이슈 발생 시 해결하기가 쉽지 않다.

 

malloc 함수에서 다루는 동적할당이라는 동작 자체가 메모리를 할당 받고 주소를 전달받는 방식인데,

이때 내가 원하는 주소를 받을 수 있는 것은 아니고 요청한 사이즈 만큼 할당 받은 곳의 주소를 줄 뿐이다.

 

그렇다면 사용할 배열의 공간이 모자랄 때 뒤이어서 추가적으로 메모리를 확장한다는 게 가능할까?

처음에 동적 할당을 받을 때 충분히 넉넉한 공간을 잡아 줘야 한다.

즉, 두 칸을 쓸 수 있도록 할당했다면 그 공간이 다 찼을 때, 두 칸을 뒤이어 붙일 것이 아니라 한번에 네 칸을 넣을 만큼 새로 공간을 받아야 한다.

 

따라서 가변 배열의 데이터에 현재 할당한 데이터가 꽉 찼을 때는 공간을 늘려야한다.

이 공간을 늘리는 규칙에 대해서 알아보자.

 

1. 기존에 들어갈 사이즈보다 더 큰 사이즈의 공간을 할당해야한다.

// 1. 2배 더 큰 공간을 동적할당한다.
int* pNew = (int*)malloc(_pArr->iMaxCount * 2 * sizeof(int));

따라서 들어갈 사이즈가 기존 최대 카운트 값에서 일정 배 한 만큼에서 자료형 타입의 사이즈만큼 공간을 할당한다.

그러면 새롭게 할당된 주소가 나오게 된다.

이 주소를 지역변수로 받아 놓아야 활용을 할 수 있다.

 

2. 기존 공간에 있던 데이터들을 새로 할당한 공간으로 복사시킨다.

기존에 있던 데이터들을 그대로 옮겨갈 수 있도록 새로 할당한 공간에 복사시킨다.

for (int i = 0; i < _pArr->iCount; ++i)
{
	pNew[i] = _pArr->pInt[i];
}

 

 

3. 기존 공간은 메모리 해제

이제 모든 값을 새 배열에 복사했으므로 이전 배열의 값들은 필요가 없어졌다. 기존 공간의 메모리를 전부 해제한다.

free(_pArr->pInt);

 

4. 배열에 새로 할당된 공간을 가리키게 한다.

새롭게 할당한 데이터를 제대로 사용하려면 배열의 주소와 연결하여 실제로 그 공간을 가리키게 해야 한다.

_pArr->pInt = pNew;

 

5. MaxCount 변경점 적용

배열이 변경되었으니 올바른 작동을 위해 데이터가 최대 몇 개까지 들어갈 수 있는지 표시하는 MaxCount 값을 수정한다.

_pArr->iMaxCount *= 2;

 

아래는 전체 코드이다.

 

<main.cpp>

#include <iostream>

#include "Arr.h"

int main()
{
	tArr s1 = {};
	InitArr(&s1);

	for (int i = 0; i < 10; i++)
	{
		PushBack(&s1, i);
	}

	for (int i = 0; i < s1.iCount; ++i) // 미리 보유하고 있는 개수만큼 반복문을 돌면서
		                                // 데이터를 하나씩 출력한다.
	{
		printf("%d\n", s1.pInt[i]);
	}

	Reallocate(&s1);

	ReleaseArr(&s1);

	return 0;

}

 

 

<Arr.h>

#pragma once

typedef struct _tagArr
{
	int* pInt;     // 데이터가 시작되는 지점을 알고 있어야 한다

	int	iCount;    // 다음 데이터가 어디부터 시작인지 알기 위해 현재까지
					  // 들어온 데이터가 몇 개인지 알아야 한다   


	int iMaxCount; // 데이터가 최대 몇 개까지 들어갈 수 있는지 한계치를 알아야 한다

}tArr;


// 배열 초기화 함수
void InitArr(tArr* _pArr);

// 데이터 추가 함수
void PushBack(tArr* _pArr, int _iData);

// 공간 추가 확장
void Reallocate(tArr* _pArr);
 
// 배열 메모리 해제 함수
void ReleaseArr(tArr* _pArr);

 

<Arr.cpp>

#include "Arr.h"

#include <iostream>

void InitArr(tArr* _pArr)
{
	// '->' 멤버접근
	
	_pArr->pInt = (int*)malloc(sizeof(int) * 2); // 미리 8 바이트만큼의 공간을 할당
	_pArr->iCount = 0;
	_pArr->iMaxCount = 10;

}

void PushBack(tArr* _pArr, int _iData)
{
	// 배열에 데이터를 추가하기 전에 먼저 현재 개수와 최대 개수가 같은지 체크해야한다.
	
	if (_pArr->iMaxCount <= _pArr->iCount) // 힙 영역에 할당한 공간이 다 찬 경우
	{
		// 재할당
		Reallocate(_pArr);

	}

	// 공간 문제가 없어 할당이 가능한 경우
	// 데이터 추가 (현재 주소의 첫번째 칸부터 데이터를 추가한다) 
	// 가변 배열 객체 안에 힙 메모리 주소 변수와 카운트를 동시에 세고 있다.
	// 그 다음에 들어갈 것은 가변 배열이 가진 주소 변수로 가서
	// 인덱스 접근을 할 건데, 그때의 인덱스가 본인이 가진 카운터 값이다.
	// 그 카운터 값을 가지고 주소에 접근해서 그 위치의 데이터를 수정하고 나면
	// 카운트 값이 증가해야 개수가 1 증가하면서 
	// 또 다음 번에 그걸 그대로 맞물려 돌아간다.
	// 따라서 후위 연산자를 사용해 입력까지 다 끝나고 나서 카운터 값이 증가하도록 한다.
	_pArr->pInt[_pArr->iCount++] = _iData;

}

// 공간 추가 확장
void Reallocate(tArr* _pArr)
{
	// 1. 2배 더 큰 공간을 동적할당한다.
	int* pNew = (int*)malloc(_pArr->iMaxCount * 2 * sizeof(int));

	// 2. 기존 공간에 있던 데이터들은 새로 할당한 공간으로 복사시킨다.
	for (int i = 0; i < _pArr->iCount; ++i)
	{
		pNew[i] = _pArr->pInt[i];
	}

	// 3. 기존 공간은 메모리 해제
	free(_pArr->pInt);

	// 4. 배열이 새로 할당된 공간을 가리키게 한다.
	_pArr->pInt = pNew;

	// 5. 
	_pArr->iMaxCount *= 2;
}

void ReleaseArr(tArr* _pArr)
{
	free(_pArr->pInt); // 할당한 메모리를 해제함
	_pArr->iCount = 0; // 
	_pArr->iMaxCount = 0;
}

추가로 재할당과 같은 중요한 기능을 하는 함수가 메인 함수에서 함부로 호출되지 않게 하기 위해서 특정 기능을 헤더와 CPP 파일에 묶어서 자료형으로 구현을 하면 헤더에 추가하지 않고 CPP에서 구현함으로서 접근을 막을 수 있다.

 

  • 가변 배열의 활용 : 버블 정렬 구현하기

    <Arr.h>
void Sort(tArr* _pArr);

 

     <Arr.cpp>

void Sort(tArr* _pArr)
{
	// 데이터가 1개 이하면 정렬하지 않음
	if (_pArr->iCount <= 1)
		return;

	while (true)
	{
		bool bFinish = true;

		int iLoop = _pArr->iCount - 1;

		// 오름차순 정렬
		for (int i = 0; i < iLoop; ++i)
		{
			if (_pArr->pInt[i] > _pArr->pInt[i + 1])
			{
				int iTemp = _pArr->pInt[i];
				_pArr->pInt[i] = _pArr->pInt[i + 1];
				_pArr->pInt[i + 1] = iTemp;

				bFinish = false;

			}
		}

		if (bFinish)
			break;

	}

}

 

<main.cpp>

int main()
{
	tArr s1 = {};
	InitArr(&s1);

	// 시드 값을 사용한 난수는 시드값이 달라지면 안의 출력 패턴도 변화한다.
	// 단, 일일이 시드 값을 숫자로 변경하는 것이 비효율적이므로
	// 여기서는 시간 값을 가져와 변화시켜주었다.
	// 이렇게 하면 절대 숫자가 겹칠 일이 없다.
	// 하지만 이것이 완벽한 난수 방법은 아니다.
	// 3D 쉐이더에서는 이런 방식을 사용할 수 없다.
	// 쉐이더에서는 동일 시간 때의 난수를 만들어야 하기 때문이다.
	// 지금 이 순간 동시에 난수가 여러 개 존재할 수가 있다.
	// 그런데 이런 방식으로 하면 같은 시간대에
	// 다 동일한 난수가 나오기 때문에 사용이 불가능하다.
	// 이처럼 어떤 상황에서는 난수가 시간 영향을 받지 않고 나와야하는 경우가 있으므로
	// 난수 관련해서는 고민해야 할 부분이 많다.
	srand(time(nullptr));

	// 랜덤으로 보이지만 완벽한 랜덤은 아니다. (나오는 숫자가 매번 같음)
	rand();


	// 1~100까지의 수 중 랜덤한 수를 할당하려고 함
	for (int i = 0; i < 10; i++)
	{
        // 반복문을 돌면서 얻은 랜덤한 숫자를 100으로 나눠 나머지 연산을 한 다음 플러스 1 하면 
        // 범위는 전체 숫자에 다같이 결괴에 1씩 더하므로 1부터 100사이의 값을 가지게 된다.
		int iRand = rand() % 100 + 1;
		PushBack(&s1, iRand);
	}

	// 일반 출력
	printf("정렬 전\n");
	for (int i = 0; i < s1.iCount; ++i) // 미리 보유하고 있는 개수만큼 반복문을 돌면서
		                                // 데이터를 하나씩 출력한다.
	{
		printf("%d\n", s1.pInt[i]);
	}


	// Sort 함수를 이용해 정렬 한 뒤 출력
	Sort(&s1);


	printf("\n");
	printf("정렬 후\n");
	for (int i = 0; i < s1.iCount; ++i)					
	{
		printf("%d\n", s1.pInt[i]);
	}
    
	return 0;

}

실행결과

 

'C,C++' 카테고리의 다른 글

함수 포인터  (0) 2023.01.27
리스트  (0) 2023.01.26
동적할당  (0) 2023.01.23
구조체 포인터  (0) 2023.01.20
문자, 문자열  (0) 2023.01.19
카테고리
작성일
2023. 1. 23. 08:04
작성자
risehyun
  • 메모리 동적 할당 및 관리

    우리가 이전에 알아본 변수들은 다음과 같다.

    스택 영역을 사용하는 지역 변수
    데이터 영역을 사용하는 전역, 정적, 외부 변수

    지금까지는 이러한 변수를 선언 및 정의하는 방법으로 메모리를 사용해왔다.
    이런 방식으로 메모리를 사용할 때는 별다른 관리 책임이 따르지 않았다.
    즉, 메모리를 할당하고 다시 운영체제에 반환하는 일련의 과정에 대해 프로그래머가 딱히 할 일이 없었다.
    이런 변수들을 자동변수라고 한다.

    하지만 자동 변수들의 경우 런타임 중 실시간으로 대응할 수 없다.
    변수는 개발자가 이미 만들어놓은 데이터 영역으로 프로그램 시작 시 얼마큼 쓸지 미리 정해진 채로 사용되기 때문이다. 그러나 프로그램을 사용할 때는 종종 특정 변수가 필요할 수도 있고, 아닐 수도 있는 상황이 발생한다.

    EX. 엔터 키를 눌렀을 때 특정 메시지가 출력되는 경우, 사용자가 프로그램 사용 중에 엔터키를 누를 수도 있고 아닐 수도 있다.

    이 경우 이 변수가 필요한지 여부는 직접 프로그램을 실행시켜봐야 알 수 있다.
    이런 문제를 해결하기 위해 사용하는 것이 바로 힙 영역에 저장되는 동적할당 변수이다.

    이때 사용하는 malloc()과 free()함수가 바로 메모리를 동적으로 할당 및 해제하는 함수이다.
    그중에서 먼저 malloc() 함수에 대해 알아보자.


  • malloc() 함수

    - void *malloc(size_t size);
    인자 : size - 할당받을 메모리의 바이트 단위 크기
    반환값 : 힙 영역에 할당된 메모리 덩어리 중에서 첫 번째 바이트 메모리의 주소로 에러가 발생하면 NULL을 반환한다.
    설명 : 할당받은 메모리는 반드시 free() 함수를 사용하여 메모리를 반환해야 하며, 메모리를 초기화하려면 memset() 함수를 사용해야 한다. 기본적으론 쓰레기 값이 들어가 있다.

<size_t >
부호 없는 정수형(unsinged integer)으로 sizeof, alignof, offsetof의 반환 값이다.
해당 시스템에서 어떤 객체나 값이 포함할 수 있는 최대 크기의 데이터를 표현하는 타입으로, 반드시 unsigned 형으로 나타낸다.

size_t는 32비트 운영체제에서는 부호없는 32비트 정수이고, 64비트 운영체제에서는 부호없는 64비트 정수이다. 크기만 보았을 때는 unsigned int 또는 int와 동일한 것 같지만 int는 os 환경이 64비트라고 해서 반드시 64비트 정수가 되는 것이 아니라 32비트 일 수 있다.

따라서 메모리나 문자열 등의 길이를 구할 때는 크기가 unsigned int 대신에 size_t 형으로 길이를 반환한다.

 

말록 함수는 인수로 전달받은 정수만큼 바이트 단위 메모리를 동적할당하고 주소를 반환한다.
이 주소는 할당받은 메모리 전체에 대한 기준주소가 된다. 따라서 이 주소를 기준으로 나머지에 접근(사용)할 수 있다.

int main()
{
    int* pInt = (int*)malloc(100);
    
    return 0;
}

 

이 코드는 수행되는 그 순간에 100 바이트가 (말록의 단위는 바이트이다) heap 영역에 만들어지도록 한다.
그리고 포인터 변수에 그 주소값의 시작 부분을 준다.

원래 말록 함수는 사용자가 요청한 만큼 메모리를 할당하고 순수한 주소를 줄 뿐, 거기에 대한 말록 자신의 견해는 없다.

위의 경우 받아가는 쪽에서 자기가 그것을 어떤 목적으로 쓸지 알맞은 포인터 타입(int*)로 가르키는 방식으로 조작을 한다.

하지만 위의 코드는 말록 함수 앞에 (int*) 캐스팅이 되어 있기 때문에 앞으로 어떤 값을 받더라도 그것을 int 타입으로 저장하게 된다.

 

위에서 주소값의 시작점을 받았으므로 이제 그 주소에 접근할 수 있다.

*pInt;

 

말록 함수의 인자로 넘긴 바이트 값은 실시간으로 프로그램 실행 중에 만들어질 수 있어야 하기 때문에 void 포인터 형태로 넘어간다. 그렇기 때문에 그렇게 넘어간 주소에 담긴 데이터를 무엇으로 볼지는 다음과 같이 받아올 포인터 변수의 타입에 의해 결정된다.

float* pF = malloc(4); // 실시간으로 힙 영역에 할당된 4바이트 데이터의 주소를 받아온다.
                       // 이 주소에 있는 데이터는 받아온 시작지점으로부터 4바이트만큼의 실수 타입인
                       // float 값으로 해석된다.

 

같은 이치로 역참조시 그 공간이 어떻게 활용될지도 사용자가 정하게 된다.

 

동적할당의 특징

1. 런타임 중에 대응 가능

2. 사용자가 직접 관리 해야 함 (할당한 메모리에 대한 해제 필요)

-> 일반 변수의 경우에는 프로그램이 시작됬을 때 필요한 메모리가 할당되고, 그렇기 때문에 프로그램 종료시에 할당한 만큼 자동으로 해제해주기 때문에 별도의 메모리 해제가 필요 없다.

그러나 동적할당 메모리의 경우 얼마큼의 메모리가 할당될지 예측할 수 없으므로 사용자의 관리가 필요하다. 임의로 할당한 메모리에 대한 해제를 반드시 해줘야한다. 만약 제대로 메모리를 해제하지 않고 쓰던 공간을 그대로 남겨놓고 프로그램을 종료하면 메모리 누수가 발생한다. 이런 프로그램을 계속 여러번 실행시키면 메모리가 반복적으로 사용된다고 체크되면서 해당 메모리들이 쌓여간다. 이런 메모리를 연속적으로 쌓아가게 되면 결국 메모리 부족 현상으로 연결된다.

 

  • 할당된 메모리 해제
    동적 할당된 메모리는 사용이 끝난 다음에는 반드시 free() 함수를 이용해 메모리를 운영체제에 반환해야 한다.
    할당은 받았으나 반환하지 않은 메모리는 사용할 수 없기 때문이다.
    이처럼 개발자 실수로 메모리를 반환하지 않아 메모리를 사용할 수 없게 되는 현상을 '메모리 누수(memory leak)'락ㅎ 한다.

    - void free(void *memblock);
    인자 : memblock - 반환할 메모리 주소
    반환값 : 없음
    설명 : 동적으로 할당받은 메모리를 운영체제에 반환하는 함수

     free()함수의 인자로는 malloc() 함수가 반환한 기준주소를 명시해야 한다.
    주의해야 할 점은 free 함수에 힙 메모리 영역이 아닌 스택 메모리나 전역변수 주소를 넣어주면 안된다는 것이다. (동적할당 데이터는 모두 힙 영역에 들어가며 나머지 영역에 있는 변수들의 메모리는 프로그램 종료시 알아서 해제되기 때문이다) 또한 메모리에서 해제되는 것은 변수가 아니라  변수 안에 들어 있는 값을 전달받아 이것을 해제한다는 점을 주의하자.
int main()
{
    int iInput = 0;
    scanf_s("%d:", &iInput); // 사용자가 입력한 값을 할당
    
    int* pInt = nullptr;
    
    if (100 == iInput)
    {
    	pInt = (int*)malloc(100);
    }
    
    if (nullptr != pInt)
    {
    	free(pInt); // 할당된 메모리를 해제한다.
    }
    
    return 0;

}

 

위에서 언급한 메모리 해제를 제대로 하지 않는 문제로 인해 오류가 발생하지 않도록 현재 메모리가 세고 있는지, 안 쓰고 있는지를 체크해서 어디가 문제인지 어느정도 찾을 수 있도록 해주는 함수도 있다.

따라서 최대한 문제를 다 잡아낸 뒤 최종 빌드 버전에서는 문제가 없도록 프로그램을 만들어 배포해야 한다.

 

C,C++ 처럼 사용자가 직접 메모리를 할당/해제 하지 않고 언어 자체에서 메모리를 관리해주는 다른 언어들의 경우 가상머신을 통해서 작성한 코드가 실행되기 때문에 작성한 코드 중에 동적할당이 필요한 부분이 있으면 내부적으로 모아두는 기능이 있고, 프로그램 종료시 이 가상 메모리에서 해제 한다. 하지만 퍼포먼스를 더욱 끌어올리고 싶을 때 프로그래머가 직접 관여할 수 없기 때문에 더욱 어려움을 겪을 수 있다. 게임에서 C++을 선호하는 이유도 그런 이유에서이다.

또 다른 예제는 다음과 같다.

#include <stdio.h>

// malloc() 함수를 사용하기 위한 헤더
#include <stdlib.h>

int main(void)
{
	int* pList = NULL, i = 0;

	// 12바이트(sizeof(int) * 3) 메모리를 동적 할당하고 시작 주소를
	// pList 포인터 변수에 저장
	// malloc 함수의 반환 자료형이 void이기 때문에 void*를 int*로 강제 캐스팅함
	pList = (int*)malloc(sizeof(int) * 3);

	// 동적 할당한 대상 메모리를 배열 연산자로 접근한다.
	pList[0] = 10;
	pList[1] = 20;
	pList[2] = 30;

	for (i = 0; i < 3; i++)
	{
		printf("%d\n", pList[i]);
	}

	// 동적 할당한 메모리를 해제한다.
	free(pList);

	return 0;
}


pList = (int*)malloc(sizeof(int) * 3);의 결과는 int가 4바이트 정수이기 때문에 4에 3을 곱한 12가 된다.
따라서 malloc() 함수의 인수를 12라고 쓴 것과 같은 결과가 된다.
그리고나서 함수가 반환하는 기준 주소를 pList 포인터 변수에 대입한다.

여기서 한 가지 중요한 점은 malloc() 함수의 반환 자료형은 void* 이고 l-value인 pList의 자료형은 int*라는 점이다.
이렇게 서로 다른 반환 자료형을 가지고 있기 때문에 void*를 int*로 강제 형변환(캐스팅) 하여 대입한다.
C언어에서는 이 강제 형변환을 수행하지 않아도 되지만 C++에서는 매우 강력하게 이 문제를 지적한다.
따라서 소스코드 호환을 위해서는 대부분 형변환 연산을 추가하는 것이 일반적이다.

또한 void*에서 void란 길이도 해석 방법도 없다는 의미이다.
일반 자료형은 일정 길이(=크기)의 메모리에 저장된 정보를 의미할 때 void는 길이와 해석 방법이 없다는 뜻이 된다.

즉, 본질이 포인터인 것은 맞지만 이 주소가 가리키는 대상 메모리를 어떤 형식(자료형)으로 해석할지는 아직 결정되지 않았다는 것이다. 따라서 void형은 해석과 인스턴스화가 불가능하다.

void형 변수를 선언하는 것은 마치 "키가 0cm이고 성별을 알 수 없는 사람의 얼굴을 그려라"와 같은 말이 된다.
한마디로 불가능한 일이다.


  • 메모리 초기화 및 사용(배열)
    메모리는 운영체제로부터 빌려오는 것이기 때문에 그 안에 어떤 정보가 담겨있을지 알 수 없다(쓰레기값).
    따라서 변수를 선언하면 할당된 메모리는 반드시 0으로 초기화 해야 한다.
#include <stdio.h>

// malloc(), calloc()함수를 위한 헤더 포함
#include <stdlib.h>

// memset() 함수를 위한 헤더 포함
#include <string.h>

int main(void)
{
	int* pList = NULL, * pNewList = NULL;

	// A. int형 3개 배열 선언 및 정의(0 초기화)
	int aList[3] = { 0 };

	// B. int형 3개를 담을 수 있는 크기의 메모리를 동적으로 할당한 후
	// 메모리를 모두 0으로 초기화

	pList = (int*)malloc(sizeof(int) * 3);
	memset(pList, 0, sizeof(int) * 3);

	// C. int형 3개를 담을 수 있는 메모리를 0으로 초기화 한 후 할당받음
	pNewList = (int*)calloc(3, sizeof(int));

	// 동적 할당한 메모리들을 해제
	free(pList);
	free(pNewList);
	return 0;
}
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	// 선언할 배열 요소의 크기를 기술하지 않았지만 초기값을 기준으로
	// 자동으로 크기가 결정 된다. (NULL 문자 저장될 크기 포함)
	char szBuffer[] = { "Hello" };

	// "Hello" 문자열이 저장된 메모리의 주소로 초기화되는 포인터 변수
    // 배열처럼 사용할 수는 있으나 포인터가 가리키는 대상 메모리가 읽기만 가능한 영역이므로
    // l-value 형태로 사용할 수 없고 오로지 r-value 형식으로만 사용할 수 있다.
	char *pszBuffer = "Hello";

	// 동적 할당한 메모리의 주소가 저장될 포인터 선언 및 정의
	char* pszData = NULL;

	// 메모리를 동적으로 할당하고 "Hello" 문자열로 초기화
	pszData = (char*)malloc(sizeof(char) * 6);
	pszData[0] = 'H';
	pszData[1] = 'e';
	pszData[2] = 'l';
	pszData[3] = 'l';
	pszData[4] = 'o';
	pszData[5] = '\0'; // 문자열의 끝을 나타내는 NULL 문자

	puts(szBuffer);
	puts(pszBuffer);
	puts(pszData);

	// 동적 할당한 메모리 해제
	free(pszData);

	return 0;
}

 

 

 - void *memset (void *dest, int c, size_t count);
인자 : dest     - 초기화할 대상 메모리 주소
          c          - 초기값, 이 값이 0이면 메모리를 0으로 초기화
          count   - 초기화 대상 메모리의 바이트 단위 크기
반환값 : 대상 메모리 주소
설명 : 동적으로 할당받는 메모리에는 쓰레기 값이 있으므로 일반적으로 0으로 초기화 하여 사용한다.

첫번째 인자로 전달된 메모리 주소를 시작으로, 세 번째 인수로 전달된 바이트 단위 길이의 메모리에 두 번째 인자로 전달받을 정보를 대입한다.
두 번째 인수에 기술할 값은 1바이트로 표현할 수 있는 범위로 제한된다. 메모리의 주소는 1바이트 단위로 부여되어 있다는 사실을 기억하자.

memset() 함수는 동적 할당한 크기만큼 모두 초기값으로 초기화하는데, 대부분 초기값을 0으로 두고 메모리를 초기화하기 때문에 이를 대신해 처음부터 메모리를 0으로 초기화하는 calloc() 함수를 사용하는 것도 좋다.

 

 - void *calloc(size_t num, size_t size);

인자 : num - 요소의 각 개수

          size - 각 요소의 바이트 단위 크기
변환값 : 힙 영역에 할당된 메모리 덩어리중 첫 번째 바이트 메모리 주소, 할당된 메모리 크기는 num 인자와 size 인자의 값을 곱한 크기이다. 에러가 발생하면 NULL을 반환한다.
설명    : 이 함수는 malloc() 함수와 달리 동적 할당할 메모리의 바이트 단위 크기만 인수로 받는 것이 아니라, 마치 배열 선언처럼 요소의 크기와 개수를 각각 받는다. 즉 할당받은 메모리를 0으로 초기화하여 전달한다.

 

  •  동적 할당된 메모리 크기 확인하기
    동적 할당된 메모리 크기는 할당 당시에 확실히 명시해야 하므로 할당 받는 입장에서 그 크기를 알고 있을 수 밖에 없다. 하지만 간혹 함수 단위로 코드가 분할되면서 포인터로 주소를 받긴 하지만 크기를 받지 못해 할당된  메모리 크기를 모를 때가 있다. 이럴 땐 _msize() 함수를 사용하면 크기를 알아 낼 수 있다. 
#include <stdio.h>
#include <malloc.h>

int main(void)
{
	char* pszData = NULL;

	pszData = (char*)malloc(sizeof(char) * 6);

	// _msize => 동적 할당된 메모리 크기 확인
	printf("%d\n", _msize(pszData));
	free(pszData);

	return 0;
}

 

  • 메모리 복사
    단순 대입 연산자는 오른쪽 l-value에 오른쪽 r-value의 정보를 왼쪽 피연산자인 l-value에 복사한다.
    이 과정에서 l-value의 기존 정보는 유실되고 새 정보로 덮어쓴다.
    이 때 주의할 점은 이 단순 대입 연산자의 피연산자가 변수(l-value)일 경우, 그 인스턴스 개수가 무조건 1개라고 전제하고 대입이 이뤄진다는 것이다.

    즉, 배열처럼 여러 인스턴스가 한 묶음으로 있는 경우엔 절대 단순 대입으로 r-value를 l-value로 복사할 수 없다.
    이는 배열에 값을 할당할 때 단순 배열이름에 = 연산자를 사용해 값을 할당할 수 없었던 것과 같은 이치다.

    배열의 이름이 식별자인 건 맞지만 변수가 아니라 '주소상수'에 부여된 식별자이므로 l-value가 되지 못한다.
    따라서 배열에 값을 대입하려면 각 배열의 요소만큼 반복문을 수행하여 요소별로 일일이 단순 대입 연산을 수행해야 한다.

    이 과정이 번거롭기 때문에 memcpy() 함수를 사용하면 이 함수가 반복을 대신해준다.

    - void *memcpy(void *dest, const void *src, size_t count);
    인자 : dest - 대상 메모리 주소
               src  - 복사할 원본 데이터가 저장된 메모리 주소
            count - 복사할 메모리의 바이트 단위 크기
    반환값 : 대상 메모리 주소
    설명 : 특정 주소로 시작하는 일정 길이의 메모리에 저장된 값을 대상 메모리에 그대로 복사해준다. 
#include <stdio.h>

// 인수로 전달된 두 주소의 메모리에 담긴 정보(원본값)을 복사하는 함수인
// memcpy() 함수를 사용하기 위한 헤더를 포함한다.

#include <string.h>

int main(void)
{
	char szBuffer[12] = { "Hello World!" };
	char szNewBuffer[12] = { 0 };

	// 원본에서 4바이트만 대상 메모리로 복사한다.
	memcpy(szNewBuffer, szBuffer, 4);
	puts(szNewBuffer);

	// 원본에서 6바이트만 대상 메모리로 복사한다.
	memcpy(szNewBuffer, szBuffer, 6);
	puts(szNewBuffer);

	// 원본 메모리 전체를 대상 메모리로 복사
	memcpy(szNewBuffer, szBuffer, sizeof(szBuffer));
	puts(szNewBuffer);

	return 0;
}

 

다음의 잘못된 예시를 통해 메모리 복사시 주의할 점에 대해 알아보자.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
	char szBuffer[12] = { "HelloWorld" };
	char* pszData = NULL;

	pszData = (char*)malloc(sizeof(char) * 12);
	pszData = szBuffer; // 동적할당한 메모리의 주소가 szBuffer라는 배열의 이름(주소)를 덮어써버렸다. 
					    // 따라서 안에 든 원본 값이 아닌 주소만 단순 대입되었다.
	puts(pszData);

	// 동적할당한 메모리를 해제하는 코드가 없어 메모리 누수가 발생한다.

	return 0;
}

 

위 코드에서 pszData에 원래 담겨있던 정보는 단순 대입연산으로 인해 유실되버리므로 이를 해제할 방법이 없게 된다.
따라서 할당은 받았으나 프로그램 종료 전 메모리를 해제하지 않았으므로 메모리 누수가 발생해버린다.


본래대로라면 위 코드는 단순히 주소 하나를 복사(shallow copy, 얕은 복사)하는 것이 아닌 대상 메모리가 가진 내용을 복사(deep copy, 깊은 복사) 하려는 의도를 가지고 작동해야 한다.


원래의 의도를 반영하여 코드를 수정하면 다음과 같다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
	char szBuffer[12] = { "HelloWorld" };
	char* pszData = NULL;

	pszData = (char*)malloc(sizeof(char) * 12);
	strcpy(pszData, szBuffer);
	puts(pszData);

	free(pszData);
	return 0;
}



'C,C++' 카테고리의 다른 글

리스트  (0) 2023.01.26
가변 배열  (0) 2023.01.24
구조체 포인터  (0) 2023.01.20
문자, 문자열  (0) 2023.01.19
[C] 함수 응용  (0) 2022.10.01