이터레이터는 '원소를 순서대로 접근하는 반복 오브젝트' 정도로 이해하면 되겠다.

'순서'는 거꾸로 혹은 임의의 방식으로 돌아갈 수 있다.

 

우리는 가장 기본적인 '처음부터 마지막'순서의 이터레이터만 구현해보자

 


template <typename Data>
class MyVector : public Container_Master<Data>
{

public:

	class iterator
	{
	private:
		Data* pos = nullptr;
	public:
		iterator(Data* pos = 0)
		{
			this->pos = pos;
		}
		Data& operator* ()const
		{
			return *pos;
		}

		bool operator!=(const iterator& iter)const
		{
			return pos != iter.pos;
		}
		bool operator==(const iterator& iter)const
		{
			return pos == iter.pos;
		}

		//전위
		iterator& operator++()
		{
			++pos;
			return (*this);
		}
	};
    
public:
    	iterator begin()
	{
		iterator iter(this->my_base);
		return iter;
	}
	iterator end()
	{
		iterator iter(this->my_base + this->my_size);
		return iter;
	}
};

나는 벡터클래스 내부에 이터레이터클래스를 생성했다.

따로 이터레이터클래스를 만들어도 되는데 이터레이터는 이정도만 구현해도 될거같아서 그랬다.

 

코드에서 나오지만 이터레이터는 '원소를 가리키는 포인터'정도로 이해하면 된다.

포인터는 +1을 하면 포인터의 크기만큼 메모리상에서 이동하게 되는데

포인터의 크기는 곧 주소의 크기고 32비트 환경에서는 4바이트, 64비트 환경에서는 8바이트의 사이즈를 가지게 된다.

 

빈공간없이 붙어있는 벡터의 경우

begin()를 보면 벡터 내부의 배열을 이터레이터생성자로 넘기면

배열의 첫 부분을 가리키는 포인터를 이터레이터가 가지게 된다.

거기서 ++iter를 하게 된다면, 이터레이터 내부의 포인터도 ++pos을 하게 되고,

포인터의 크기만큼 이동하게 되므로 당연히 다음 포인터를 가리키게 되고

그 결과 이터레이터는 다음 원소를 가리키게 된다.

이런 방식이라고 보면된다.

 

이터레이터로 루프돌리는 방법은

for (MyVector<int>::iterator it = my_vector.begin(); it != my_vector.end(); ++it)
{
	cout << (*it) << endl;
}
    
for (auto it = my_vector.begin(); it != my_vector.end(); ++it)
{
	cout << (*it) << endl;
}

for (auto it : vector)
{
	cout << (it) << endl;
}

MyVector<int>::iterator를 직접 선언해서 사용해도 되지만 상당히 귀찮기 때문에

c++11이후로 변경된 'auto'키워드를 사용하서 간편하게 다뤄도 된다

 

range-based for loop방식을 사용하면 더 간편하게 사용할 수 있다.

이 방법도 c++11에서 생긴 방법이다

 


이제 거의 모든 기능을 구현했지만 하나의 기능을 아직 구현하지 않았다.

그것은 벡터의 원소에 빠르게 접근하기 위한 operator[]

	Data& operator[] (const size_t index)
	{
		//인덱스가 유효하면 인덱스 위치에 보관한 자료를 반환하세요.
		this->CheckValidIndex(index);

		//데이터를 반환합니다
		//반환 형식이 참조 형식임을 주의하세요.
		return this->my_base[index];
	}

어렵지 않다. 벡터에 []을 사용하면 내부에서 배열에 []을 사용해서 원소를 가져오는 방법이다.

인덱스가 유효한지 검사하는 함수만 추가되었을 뿐이다.

 


이렇게 벡터의 기본적인 함수를 구현했다.

예제코드에 있지만 여기선 설명하지 않은 함수가 있다. 어려운 코드가 아니니 금방 이해하실 수 있다.

 

https://github.com/ForestBird1/MyContainer

 

GitHub - ForestBird1/MyContainer

Contribute to ForestBird1/MyContainer development by creating an account on GitHub.

github.com

 

벡터의 특징중 하나인 어느 위치에서든 원소의 삽입과 삭제를 고려해서 짜야한다


삽입코드

	//마지막 위치에 원소를 추가합니다
	void Add(const Data& data)
	{
		Insert(this->Num(), data);
	}
	
	//정해진 위치에 원소를 추가합니다
	//원소 삽입시 기존 위치의 원소부터 마지막원소까지 전부 인덱스가 +1씩 밀리기 때문에
	//원소가 많고 0의 가까운위치에 삽입될수록 느려질 수 있습니다
	void Insert(const size_t insert_index, const Data& data)
	{
		//컨테이너에 비어있는 자리가 있는지 확인
		if (this->my_size == this->my_capacity)
		{
			//빈자리 없음. 재할당을 시도합니다
			this->Reserve(this->my_capacity + this->additive_capacity);
		}


		//컨테이너 중간에 원소가 들어간다면 원소를 해당 지점부터 한칸씩 뒤로 이동시킵니다.
		for (size_t i = this->my_size; i > insert_index; --i)
		{
			this->my_base[i] = this->my_base[i - 1];
		}
		

		//추가합니다
		this->my_base[insert_index] = data;
		++this->my_size;
	}

Add()는 마지막위치에 원소를 삽입하는 함수이므로 Insert()만 보겠다.

 

Insert()는 아래 순서로 작동한다

1. 컨테이너(배열)에 빈자리가 있는지 확인-> 빈자리가 없다면 재할당

2. 삽입해야할 위치의 원소부터 마지막원소까지 전부 한칸씩 이동

3. 삽입해야할 위치가 비었으니 원소추가

 

여기서 우린 2번항목을 잘 봐야한다. 왜 중간에 원소를 삽입하면 퍼포먼스가 떨어지는지...

단순히 원소를 옮기는 작업이기에 가끔 한두번 삽입하는건 일반적인 환경에선 문제가 없지만,

자주, 빈번하게, 매 프레임 중간삽입을 한다면 분명히 문제를 가져올것이라고 생각한다.

 

원소가 0번째 인덱스에 가까울수록 퍼포먼스가 떨어진다. 반대로 말하면

마지막에 원소를 추가하는것은 퍼포먼스에 문제가 없다. 

 

중간삽입은 작업내용을 고려해서 사용해야 한다.

 

 


 

삭제코드

	//해당 위치의 원소를 삭제합니다
	void RemoveAt(const size_t index)
	{
		this->CheckValidIndex(index);

		--this->my_size;
		for (size_t i = index; i < this->my_size; ++i)
		{
			this->my_base[i] = this->my_base[i + 1];
		}
	}

	//원소이동의 부하를 줄이기 위해 삭제될 원소와 마지막 원소의 위치만 변경하고 삭제합니다
	//마지막 원소가 삭제된 원소의 위치로 오기 때문에 원소의 순서는 보장되지 않습니다
	void RemoveAtSwap(const size_t index)
	{
		--this->my_size;
		this->my_base[index] = this->my_base[this->my_size];
	}

RemoveAt(): 해당 위치(인덱스)의 원소 삭제.

RemoveAtSwap(): 해당 위치의 원소를 삭제후 빈공간을 마지막 원소로 대체. 순서가 보장되지 않음.

 

RemoveAt()을 보면 Insert()와 동일하게 원소를 이동시킨다. 역시 마지막 원소를 삭제하는것은 퍼포먼스에 문제가 없다.

 

벡터는 순서를 보장하는 장점이 있지만, 순서상관없이 사용하는 경우도 많이 있다.

가령 쉽게 원소에 접근하기 위해 등등...

그런 사람들을 위해 RemoveAtSwap()을 사용하면 순서는 보장되지 않지만 어느 위치의 원소를 삭제해도 퍼포먼스에 문제가 없다.

 

https://github.com/ForestBird1/MyContainer

 

GitHub - ForestBird1/MyContainer

Contribute to ForestBird1/MyContainer development by creating an account on GitHub.

github.com

 

생성자와 소멸자 코드

	MyVector()
	{
		//my_base가 nullptr지만
		//사이즈및 캐퍼시티가 0이므로 원소를 추가할때 Reserve()함수에서 할당이 이뤄집니다
		this->my_base = nullptr;
		this->my_capacity = this->my_size = 0;
	}
	MyVector(std::initializer_list<Data> list)
	{
		this->my_capacity = this->my_size = 0;
		this->Reserve(list.size());

		for (auto& element : list)
		{
			this->my_base[this->my_size++] = element;
		}
	}
    //MyVector<int> my_vector({ 1, 2, 3, 4 });
	~MyVector()
	{
		if (this->my_base)
		{
			delete[] this->my_base;
		}
	}

벡터를 생성할 때 즉시 원소를 넣고싶으면 2번째 생성자처럼

MyVector<int> my_vector({ 1, 2, 3, 4 });

이렇게 넣으면된다. 

 

소멸자가 호출되면 벡터 내부에 포인터로 작성된 배열을 해제하는것을 볼 수 있다.

 

 

캐퍼시티(Capacity)란?

캐퍼시티: 배열의 빈공간을 포함한 최대크기.

사이즈: 배열의 원소갯수.

 

std::vector는 원소를 추가할 때 사이즈가 캐퍼시티를 초과하면 새롭게 캐퍼시티를 할당합니다.

 

그냥 배열에 추가하면 알아서 되는거아냐?

새롭게 캐퍼시티를 할당하는게 왜?

어쨌든 잘 돌아가면 되는거 아냐?

 

라고 생각하면 당신은 주니어(라고 포프님이 그랬습니다)

 

캐퍼시티의 할당을 고려하지 않고 배열을 생성하게 되면 퍼포먼스의 악영향을 끼칠 수 있습니다.

단순히 10개 100개 1000개를 만드는건 크게 체감되지 않을테지만, 특정한 상황에선 고려해야합니다.

왜그럴까요? 우선 코드부터 봅시다

template <typename Data>
class Container_Master
{
protected:
	Data* my_base = nullptr;
	size_t my_capacity = 0;
	size_t my_size = 0;

	//캐퍼시티를 늘릴 때 지정된 값이 없다면 해당 값만큼 늘립니다
	const size_t additive_capacity = 32;
public:
	//컨테이너의 공간을 새로 생성합니다
	void Reserve(size_t capacity)
	{
		//기존 캐퍼시티가 더 크다면 재할당을 하지 않습니다
		if (my_capacity >= capacity)
			return;

		Data* temp = new Data[capacity];
		my_capacity = capacity;

		//컨테이너에 원소가 있다면 새로 할당한 주소로 옮겨줍니다
		if (my_size >= 1)
		{
			for (size_t i = 0; i < my_size; i++)
			{
				temp[i] = my_base[i];
			}
			delete[] my_base;

		}

		my_base = temp;
	}
};

Reserve()함수 입니다.

 

간단하게 문장으로 표현하면 이렇습니다

1. new를 사용해 새롭게 할당.

2. for루프로 기존 원소를 새롭게 할당한 곳에 전부 이동.

3. 기존에 할당된 배열(Data*)은 삭제하고 새롭게 할당된 배열을 등록합니다

 

이미지로 보자면...

기존 배열의 원소가 하나씩 이동하는것을 볼 수 있다.

첫번째 이미지 처럼 원소를 가진상태로 배열의 사이즈가 꽉차서 재할당이 이뤄지면 모든 원소가 새로운 배열로 이동하는것을 볼 수 있다. 이미지는 7개밖에 되지 않지만

만약 원소가 만개라면? 십만개라면?

게다가 재할당할때 마다 캐퍼티시는 여러개가 추가되는게 아니라 std::vector를 기준으로 1개만 추가된다.

그러면 만개의 원소를 하나 추가한다고 가정하면 1+2+3+4+5+...+9999 만큼 원소가 이동하게 되고 10000번의 재할당이 이뤄진다. 메모리 파편화의 가능성도 있다.

언리얼엔진같은 경우는 더 많이 캐퍼시티가 증가하지만 그것이 근본적인 해결책이 되진 않는다.

 

그렇기에 배열(벡터)을 생성하게 된다면 꼭 미리 할당을 해야한다.

원소를 10개 채울려면 원소를 추가하기 전에 Reserve(10)함수를 사용하면된다.

만약 원소를 몇개 채울지 모르겠다면 추정치라도 Reserve()해준다.

 


std::vector는 예기치 않게 캐퍼시티를 늘릴 경우 1만 늘어나는것을 나는 32개의 캐퍼시티를 늘리게 구현해놨다.

처음엔 기존원소개수의 2배씩 캐퍼시티를 늘릴까 생각했지만, 그렇게까지 할 필요를 못느껴서 32개로 고정했다.

32개로 설정한 이유는... 없다. 그냥 느낌이 32개가 좋다. 뭔가 적절한느낌 이다.

 

 

 

https://github.com/ForestBird1/MyContainer

 

GitHub - ForestBird1/MyContainer

Contribute to ForestBird1/MyContainer development by creating an account on GitHub.

github.com

 

벡터는 '동적 배열 클래스'입니다

 

1. 벡터는 배열(array)과 다르게 크기 수정이 언제는 가능하다

배열은 한번 크기를 설정한 이후 수정이 불가능하지만, 벡터는 언제는 크기 수정이 가능합니다.

이렇게 보면 크기수정이 매우 마법처럼 보이지만 사실은,

크기가 수정되면 새로운 배열을 만들어 기본 배열의 원소를 이동시키게 됩니다

사실은 하나의 배열을 늘렸다 줄였다 하는것이 아니라 !새로운!배열을 만들어 버립니다.

 

그렇기 때문에 벡터생성시 캐퍼시티를 미리 생성해주는것이 매우 중요합니다.

캐퍼시티를 미리 생성하지 않아도 벡터가 꽉차면 새로운 배열을 생성해서 늘려주긴 합니다만, 

상황에 따라서 퍼포먼스에 악영향을 미칠 수 있습니다.

여기서 캐퍼시티란, 벡터의 원소개수가 아니라 빈공간을 포함한 벡터의 크기입니다.

 

예를 들자면, 벡터라는 아파트에 캐퍼시티라는 빈 집을 미리 만들어놔야 원소라는 입주민을 언제든 받을 수 있습니다

만약 한층에 하나의 입주만 받는 20층짜리 아파트는 20가구의 입주민만 받을 수 있죠.

21번째 가구가 입주한다고 했을 때 이미 지어진 아파트에 1층을 올릴순 없습니다.

그래서 21층짜리 아파트를 새로 짓습니다.

그리고 이전 아파트에 살던 입주민들을 새로운 아파트로 재입주 시키게 됩니다.

그렇기 때문에 처음 아파트를 지을때 미리 캐퍼시티라는 빈 집을 입주민에 맞게 지어놔야 다시 아파트를 지을일이 없습니다.

 

꼭 추가될 원소개수를 고려해 캐퍼시티를 고려합시다.

 

2. 원소가 메모리상에 인접해 있어서 벡터의 어떤 원소든 쉽게 접근할 수 있지만,

항상 인접해야있어야하므로 벡터의 중간원소의 삽입,삭제가 일어나면 빈공간을 메꿔야하므로

빈번하게 일어나면 비효율적이다.

벡터는 모든 원소가 메모리상에 순서대로 붙어있습니다. 덕분에 원소의 순서가 보장되어 있지만, 중간에 원소가 삽입, 삭제가 발생하면 빈공간을 메꿔야하기 때문에 비효율적이고 이유는 해당 지점을 기준으로 모든 원소가 옆 메모리로 이동하게 됩니다.

 

중간삭제이슈:

위에 설명했듯이 20층짜리 벡터가 있고 입주민이 꽉차있는 상황에서 10층 입주민이 삭제라는 이사를 하게 되었습니다. 그럴때 10층을 빈 공간으로 두지 않고 11층 입주민은 10층으로, 12층입주민은 11층으로...20층입주민은 19층으로 이동하게 됩니다.

그저 원소 하나를 삭제했을 뿐인데 10개의 원소가 모두 이동하게 되었습니다.

환경에 따라서 가끔 한두번 그러는건 문제 없겠지만, 빈번하게 일어나면 퍼포먼스에 악영향을 끼치게 됩니다.

 

중간삽입이슈:

마찬가지로 20층짜리 벡터가 있고 입주민이 꽉차있는 상황에서 10층에 새롭게 입주하려는 입주민이 있습니다.

그럼 역시 기존10층입주민은 11층으로... 11층입주민은 12층으로... 20층입주민은 21층으로....

삭제와 마찬가지로 11개의 원소가 이동하게 됩니다. 게다가 21층을 필요로하니 아파트를 새로 짓기까지 해야합니다.

 

이렇게 모든 원소를 꼭 붙혀놓으려고 하는 이유는 언제는 쉽게 벡터의 원소에 접근하기 위해서 입니다.

만약 10번째 원소를 삭제했을 때 빈공간으로 놓으면 우리는 원소를 가져올 때마다 비어있는지 확인해야하고, 원소를 삽입할 때 마다 빈공간을 찾아야하고 굉장히 바빠질것입니다. 

 

순서를 보장받고싶진 않지만 벡터를 사용하고 싶을 때 사용할 수 있는 꼼수가 하나 있습니다.

순서를 보장받을 필요가 없다면 중간삽입을 할 필요가 없으므로 중간삭제에 대해서만 확인하면됩니다.

그 방법은 원소삭제후 빈 공간을 벡터의 마지막원소만 이동시켜 채워넣으면 됩니다.

 

마지막원소의 위치가 변경되었으니 순서는 바뀌었지만, 대신 중간삭제에 대해선 문제없이 할 수 있습니다.

해당 방법은 예제에 있습니다.


제가 구현한 벡터의 함수들입니다.

 

생성자()

생성자(std::initializer_list<Data>)

소멸자()

 

Reserve(): 캐퍼시티할당

Clear(): 배열의 모든원소 삭제(실제로 삭제되지 않고 배열사이즈를0으로 변경합니다. 캐퍼시티는 그대로입니다)

Num(): std::vector.size()와 동일한 함수로 배열의 원소개수를 알려줍니다

Max(): 배열의 캐퍼시티를 알려줍니다

 

Add(): 배열 마지막위치에 원소추가

Insert(): 배열의 원하는 위치에 원소추가

RemoveAt(): 배열의 원하는 위치의 원소를 삭제합니다(실제로 삭제되지 않고 배열사이즈를 1줄입니다)

RemoveAtSwap(): 배열의 원하는 위치의 원소를 삭제하고, 그 자리에 마지막원소를 가져와 채웁니다.(실제로 삭제되지 않고 배열사이즈를 1줄입니다). 해당방법을 사용하면 원소의 순서는 보장할 순 없지만, 삭제된 위치부터 마지막위치까지 원소를 +1만큼 이동시키지 않아도 됩니다.

PopBack(): 스택의 Pop()처럼 마지막 원소를 삭제하고 반환합니다.

 

그외에...

이터레이터

오퍼레이터[], =


기능을 추가로 넣는다면 Clear()는 원소사이즈를 0으로 변경하지만 새로운 함수를 만들어서 캐퍼시티까지 0으로 변경하는 방법도 있겠습니다.

Add, Num, Max등 기존 벡터함수이름와 다르게 언리얼엔진의 TArray를 참고해서 함수명을 작성했습니다.

제가 언리얼엔진을 써서 그런지 TArray의 함수명이 더 직관적이라 편하다고 생각됩니다

 

앞으로 시간될 때 공부도 할겸 Stack, LinkedList등등 기초 자료구조를 만들어서 포스팅할 생각입니다.

 


우선 클래스를 먼저 보여드리겠습니다

#pragma once

template <typename Data>
class Container_Master
{
protected:
	Data* my_base = nullptr;
	size_t my_capacity = 0;
	size_t my_size = 0;

	//캐퍼시티를 늘릴 때 지정된 값이 없다면 해당 값만큼 늘립니다
	size_t base_capacity = 32;
public:
	//컨테이너의 공간을 새로 생성합니다
	void Reserve(size_t capacity)
	{
		//기존 캐퍼시티가 더 크다면 재할당을 하지 않습니다
		if (my_capacity > capacity)
			return;

		Data* temp = new Data[capacity];
		my_capacity = capacity;

		//컨테이너에 원소가 있다면 새로 할당한 주소로 옮겨줍니다
		if (my_size >= 1)
		{
			for (size_t i = 0; i < my_size; i++)
			{
				temp[i] = my_base[i];
			}
			delete[] my_base;

		}

		my_base = temp;
	}

	//배열의 원소를 삭제하지않고
	//사이즈를 0으로 만듭니다
	//Clear()함수후에 원소룰 추가하면 기존에 있던 원소를 덮어씌웁니다
	void Clear()
	{
		my_size = 0;
	}

	//컨테이너의 원소개수입니다
	size_t Num()
	{
		return my_size;
	}

	//컨테이너의 캐퍼시티개수입니다
	size_t Max()
	{
		return my_capacity;
	}
	
    	//인덱스 유효성검사를 합니다
	//유효하지 않으면 throw
	bool CheckValidIndex(const size_t index)
	{
		if ((index >= 0) && (index < this->my_size))
		{
			return true;
		}
		else
		{
			throw printf("범위를 벗어난 인덱스를 사용하였습니다. 접근하려는 인덱스: %d, 배열크기: %d", index, this->Num());
		}
	}
};
#include "Container_Master.h"

template <typename Data>
class MyVector : public Container_Master<Data>
{

};

Container_Master를 부모를 두고 MyVector클래스가 있습니다.

벡터를 구현하다보니까 앞으로 스택을 구현할 때 중복될거같은 부분을 부모클래스로 올렸습니다.

아직 스택을 구현해본적이 없지만... 그럴거같아서요...

 

std::vector의 템플릿방식으로 클래스를 생성했습니다.

 

템플릿에 대한 자세한 내용은 여기서 확인해주세요

https://modoocode.com/219

 

https://modoocode.com/219

모두의 코드 씹어먹는 C++ - <9 - 1. 코드를 찍어내는 틀 - C++ 템플릿(template)> 작성일 : 2017-04-07 이 글은 77006 번 읽혔습니다. 에 대해서 배웁니다. 안녕하세요 여러분! 지난번 강좌 생각해보기는 잘

modoocode.com

 

벡터의 사이즈를 int, unsigned int를 사용하지 않고 size_t를 사용한 이유는 여기서 확인해주세요

https://forestbird0.tistory.com/37

 

[C++] size_t의 역할

size_t는 unsigned_int와 동일합니다 그렇다면 굳이 size_t가 존재하는 이유가 무엇일까요? size_t의 선언부를 보게되면 이렇게 되어있습니다 #ifdef _WIN64 typedef unsigned __int64 size_t; #else typedef unsigned int size_

forestbird0.tistory.com


https://github.com/ForestBird1/MyContainer

 

GitHub - ForestBird1/MyContainer

Contribute to ForestBird1/MyContainer development by creating an account on GitHub.

github.com

 

+ Recent posts