📢 언어를 효과적으로 학습하는 방법은 무언가를 만들어보는 것이죠. C++의 std::string을 구현해보며 C++을 익혀봤어요. 이름하여 GuString클래스!
전체 여정은 아래와 같아요
namespace Gu {...} 안에 모든 것들을 구현했죠
기본적으로 Class 구현 시 Class 내부에는 함수 선언만 존재하고 Class 외부에 함수 구현을 해요
- 컴파일 시간 단축 (h, cc 파일 구분 시)
- 정보 은닉
- 코드 관리 편리 (협업 및 유지보수)
- 재사용성 및 의존성 최소화
※ 단, 템플릿 클래스는 함수 구현도 class 내에 존재
Step 1. 문자열 길이와 복사 함수 직접 만들기
먼저 C표준 라이브러리를 대체 할 strlen, strcpy를 구현했어요
namespace Gu {
...
static int gu_strlen(const char* str) {
int length = 0;
while (str[length] != '\0') {
length++;
}
return length;
}
static char* gu_strcpy(char *dest, const char *src) {
char *original_dest = dest;
while ((*dest++ = *src++) != '\0');
return original_dest;
}
...
}
Step 2. 기본 생성자 만들기
const char*로부터 문자열을 복사하는 기본 생성자를 작성했어요
- nullptr 처리도 해주고 내부적으로 len_과 data_를 관리해요
- capacity_ 개념을 도입해서 메모리 재할당을 줄이고 성능을 향상시켰어요
namespace Gu {
...
class GuString {
public:
GuString(const char* data); // constructor
...
private:
char* data_;
int len_;
int capacity_;
};
GuString::GuString(const char* data){
if (data == nullptr) {
len_ = 0;
capacity_ = len_ + 1;
data_ = new char[capacity_];
data_[0] = '\0';
}
else {
len_ = gu_strlen(data);
capacity_ = len_ + 1;
data_ = new char[capacity_];
gu_strcpy(data_, data);
}
}
...
}
Step 3. 소멸자 작성
동적으로 할당한 문자열 메모리를 delete[]로 해제해요
RAII 원칙의 기본이라고 하네요 :)
namespace Gu {
...
class GuString {
public:
~GuString() {
delete[] data_;
}
...
};
...
}
Step 4. 복사 생성자 구현
깊은 복사가 되도록 메모리를 새로 할당한 뒤, 문자열을 복사해요
shallow copy가 되면 큰일나요
- 객체 간 같은 문자열 배열의 주소를 공유하여 Memory leak이 발생 가능하죠
namespace Gu {
...
class GuString {
public:
...
GuString(const GuString& other); // copy constructor
...
};
GuString::GuString(const GuString& other) {
len_ = other.len_;
capacity_ = len_ + 1;
data_ = new char[capacity_];
gu_strcpy(data_, other.data_);
}
...
}
복사 생성자는 아래의 경우들에서 호출돼요
// 복사 초기화 (copy initialization)
// s1을 사용해 새로운 객체 s2를 초기화 → 복사 생성자 호출
GuString s2 = s1;
// 직접 초기화 (direct initialization)
// s1을 사용해 s2 생성 → 복사 생성자 호출
GuString s2(s1);
// 함수 인자로 값 전달
// 함수 호출 시 복사본 생성 필요 → 복사 생성자 호출
void foo(GuString s); → foo(s1);
// 함수 반환 시 값으로 받기
// 리턴된 객체를 받으면서 복사 생성자 호출 (혹은 이동 생성자, RVO 적용 시 생략)
GuString s = make();
Step 5. 복사 대입 연산자 구현
operator=를 오버로딩해서 이미 생성된 객체에 대입할 수 있게 했어요
- 자기 자신에 대한 대입 방지, capacity_에 따라 재할당 여부도 조건부 처리했어요
namespace Gu {
...
class GuString {
public:
...
GuString& operator=(const GuString& other); // 복사 대입
...
};
GuString& GuString::operator=(const GuString& other) {
if (this == &other) {
return *this;
}
if (capacity_ >= other.len_ + 1) {
len_ = other.len_;
gu_strcpy(data_, other.data_);
}
else {
delete[] data_;
len_ = other.len_;
capacity_ = len_ + 1;
data_ = new char[capacity_];
gu_strcpy(data_, other.data_);
}
return *this;
}
...
}
아래와 같은 상황에서 복사 대입 연산자가 호출 돼요
GuString a("hello");
GuString b("world");
a = b; // 여기서 "복사 대입 연산자"가 호출됨!
Step 6. 이동 생성자 & 이동 대입 연산자
C++11부터 중요한 move semantics도 적용해봤어요
- 복사 생성자는 객체 복사 시 메모리 새로 할당 후 내용을 복사하죠
- 이동 생성자는 기존 객체의 자원을 옮겨오고(훔치고) 원래 객체는 안전하게 초기화하는 개념이죠
namespace Gu {
...
class GuString {
public:
...
GuString(GuString&& other); // move constructor
...
};
GuString::GuString(GuString&& other) {
len_ = other.len_;
capacity_ = other.capacity_;
data_ = other.data_;
other.data_ = nullptr;
other.len_ = 0;
}
...
}
그냥 소유권만 넘겨주는 방식이라 복사 생성자보다 빠르고 효율적이에요
GuString a = GuString("hello"); // rvalue로 객체 초기화: 이동 생성자
GuString b = makeTemp(); // 함수 리턴값 받기: 이동 생성자 or RVO
GuString c("test");
GuString d = std::move(c); // std::move를 사용한 명시적 이동: 이동 생성자
std::vector<GuString> vec;
vec.push_back(std::move(a)); // 컨테이너에 객체 이동: 이동 생성자
Step 7. reserve() 함수 구현
capacity를 미리 확보해두는 reserve()를 구현했어요
- 문자열 내용이 변경 될 때 길이가 모자라면 메모리 재할당을 해야하는데 이게 빈번하면 성능에 문제가 생기죠
- 사전에 문자열 max 길이를 알고 있다면 reserve()로 개발자가 미리 공간을 확보하고 진행 할 수도 있어요
이는 append()와 같은 함수에서 큰 역할을 해요
namespace Gu {
...
class GuString {
public:
...
void reserve(int new_capacity);
...
};
void GuString::reserve(int new_capacity) {
if (capacity_ >= new_capacity) {
return;
}
char* new_data = new char[new_capacity];
gu_strcpy(new_data, data_);
capacity_ = new_capacity;
delete[] data_;
data_ = new_data;
}
...
}
Step 8. append() 함수 구현
다른 문자열을 덧붙이는 함수에요
reserve()를 활용해 capacity가 부족할 경우 자동 확장되도록 했어요
namespace Gu {
...
class GuString {
public:
...
void append(const char* str);
...
};
void GuString::append(const char* str) {
int append_len = gu_strlen(str);
if(capacity_ < len_ + append_len + 1) {
reserve((len_ + append_len + 1)*2);
}
gu_strcpy(data_ + len_, str);
len_ = len_ + append_len;
}
...
}
Step 9. 연산자 오버로딩 – +
GuString끼리 concat하는 operator+를 구현했어요
- 새로운 문자열을 만들어 반환
- 깊은 복사, 메모리 할당 주의
namespace Gu {
...
class GuString {
public:
...
GuString operator+(const GuString& rhs) const;
...
};
GuString GuString::operator+(const GuString& rhs) const {
int total_len = len_ + rhs.len_;
GuString ret("");
ret.reserve(total_len + 1);
gu_strcpy(ret.data_, data_);
gu_strcpy(ret.data_ + len_, rhs.data_);
ret.len_ = total_len;
return ret;
}
...
}
Step 10. 연산자 오버로딩 – ==, !=
문자열 내용을 비교하는 연산자들을 구현했어요
- 길이부터 먼저 비교한 후
- 문자 하나하나 비교해요
namespace Gu {
...
class GuString {
public:
...
bool operator==(const GuString& rhs) const;
bool operator!=(const GuString& rhs) const;
...
};
bool GuString::operator==(const GuString& rhs) const {
if(len_ != rhs.len_) {
return false;
}
for (int i = 0; i < len_; i++) {
if(data_[i] != rhs.data_[i]) return false;
}
return true;
}
bool GuString::operator!=(const GuString& rhs) const {
return !(*this == rhs);
}
...
}
Step 11. 연산자 오버로딩 – []
문자 인덱스 접근용 operator[] 구현도 빠질 수 없죠
- 수정 가능한 버전
- 읽기 전용(const) 버전 둘 다 구현했어요 ← const char a = s[2];
이걸로 str[1] = 'a'; 도 완벽 지원돼요
namespace Gu {
...
class GuString {
public:
...
char& operator[](int index);
...
};
char& GuString::operator[](int index) {
return data_[index];
}
const char& GuString::operator[](int index) const {
return data_[index];
}
...
}
Step 12. clear() & shrink_to_fit() 구현
- clear()는 문자열 비우기
- shrink_to_fit()는 필요 없는 capacity를 줄이는 기능이에요
작지만 성능에 민감한 환경에서는 유용합니다.
namespace Gu {
...
class GuString {
public:
...
void clear();
void shrink_to_fit();
...
};
void GuString::clear() {
len_ = 0;
data_[0] = '\0';
}
void GuString::shrink_to_fit() {
if (len_ + 1 == capacity_) {
return;
}
int new_capacity = len_ + 1;
char* new_data = new char[new_capacity];
gu_strcpy(new_data, data_);
delete[] data_;
data_ = new_data;
capacity_ = new_capacity;
}
...
}
Step 13. print(), c_str(), length() 함수 구현
- print()로 콘솔에 문자열 출력
- c_str()은 C 스타일 문자열 반환
- length()는 현재 길이 리턴
namespace Gu {
...
class GuString {
public:
...
const char* c_str() const;
...
};
const char* GuString::c_str() const {
return data_;
}
...
}
Step 14. operator<< 오버로딩
마지막으로 std::cout << GuString 스타일 출력을 위해 operator<<을 구현
- std::ostream을 받아 내부 문자열 출력
- namespace Gu 내부에 정의하여 MISRA 규칙도 만족!
namespace Gu {
...
std::ostream& operator<<(std::ostream& os, const GuString& str) {
return os << str.c_str();
}
...
}
GuString Class를 만들며 아래의 것들에 대해 익혔어요
- RAII 원칙과 메모리 안전 설계
- 복사 / 이동 생성자의 개념과 차이
- 연산자 오버로딩의 유용성과 설계 철학
- capacity를 이용한 효율적인 동적 버퍼 관리
전체 소스 코드는 아래 github에서 확인 가능해요
https://github.com/geongupark/cpp-GuString
GitHub - geongupark/cpp-GuString: cpp project for String library
cpp project for String library. Contribute to geongupark/cpp-GuString development by creating an account on GitHub.
github.com
C++을 익히는데 큰 도움이 됐어요
"문자열을 구현했지만, 사실은 C++을 구현해봤다."라는 생각이 들어요
'TIL > 2025' 카테고리의 다른 글
| Arm R-Profile Architecture 학습 방법 및 자료 (0) | 2025.04.28 |
|---|---|
| Overview of Armv8-R AArch32, Cortex-R52 (1) | 2025.04.28 |
| assembly 파일 분석 방법 (1) | 2025.04.24 |
| 멀티코어 환경 section offset 기반 복사 & 재매핑 패턴 (0) | 2025.04.24 |
| 링커 스크립트 분석 5단계 (0) | 2025.04.24 |