Subscript([ ]) operator overloading
◆ 첨자 연산자 오버로딩
● [ ] 연산자
● 멤버 함수로 오버로딩 필요
☞ (복습) [ ] , ( ) , -> , = 와 같은 몇몇 연산자는 멤버 함수로만 오버로딩 가능
● 경계 검사 등 기능 확장을 위해 용이하게 사용된다
📘 첨자 연산자 오버로딩 (operator[])
✅ 개요
- [] 연산자는 배열처럼 객체에 인덱스로 접근할 수 있게 해주는 연산자이다.
- obj[i] → obj.operator[](i) 로 해석된다.
✅ 오버로딩 목적
- 단순한 배열 접근뿐만 아니라,
👉 경계 검사, 읽기/쓰기 분리, 로깅, 데이터 가공 등 기능 확장이 가능하다. - 특히 클래스 내부에서 포인터나 배열을 관리할 때 매우 유용하다.
✅ 반드시 멤버 함수로만 오버로딩 가능
- operator[] 는 예외적으로 전역 함수 오버로딩이 불가능
- 반드시 클래스의 멤버 함수로 정의해야 한다
class MyArray {
private:
int data[10];
public:
int& operator[](int index) {
return data[index]; // 경계 검사 생략 예시
}
};
전역 함수로는 정의할 수 없음 ❌
int& operator[](MyArray obj, int index); ← 오류 발생
표현 | 의미 |
첨자 연산자 | subscript operator |
operator[] | 배열처럼 객체 접근하게 해줌 |
a[i] | → a.operator[](i)로 해석됨 |
오버로딩 위치 | 멤버 함수에서만 가능함 |
📘 첨자 연산자 오버로딩과 경계 검사(Bounds Checking) 의 중요성
✅ 상황 설명
int main() {
Array a1{5, 10};
std::cout << a1.GetValue(1) << std::endl;
}
- a1은 10칸짜리 배열을 생성하고,
- GetValue(int index) 함수로 배열의 값을 확인하는 구조이다.
✅ 경계 검사 유무에 따른 차이
① 경계 검사 있음 (안전한 방식 ✅)
int GetValue(int index) const {
if (index < size && index >= 0)
return ptr[index];
}
- 유효한 인덱스(0 <= index < size)인 경우에만 배열 값을 반환한다.
- 이 조건문이 코드의 안전성을 보장해준다.
② 경계 검사 없음 (위험한 방식 ❌)
int GetValue(int index) const {
return ptr[index];
}
- 컴파일 시 에러 없음. 왜냐면 int index 파라미터에 -1이나 15를 넣어도 문법적으로는 허용되기 때문.
- 그러나 런타임에서는 잘못된 메모리 접근이 발생함
🧨 위험한 상황 시뮬레이션
std::cout << a1.GetValue(-1) << std::endl;
- 결과: 쓰레기 값 출력
- 이유: ptr[-1] 은 Heap 공간 외부, 우리가 소유하지 않은 메모리를 참조
std::cout << a1.GetValue(15) << std::endl;
- size는 10인데 15를 참조하라고 요청 → 초과 접근
- 결과: 역시 쓰레기 값, 운이 나쁘면 런타임 크래시 발생
❗ 왜 문제가 되는가?
항목 | 설명 |
문법적으로 OK | int index 에 음수나 큰 값을 넣어도 컴파일러는 에러를 내지 않음 |
런타임 위험 | 배열 경계를 벗어난 메모리를 접근 → 쓰레기 값, 크래시, 보안 취약점 가능성 |
디버깅 어려움 | 실수로 이상한 값이 들어가도 경고 없음 → 논리 오류 파악 어려움 |
✅ 결론: 첨자 연산자에는 경계 검사가 반드시 필요하다
- ptr[index] 처럼 단순하게 리턴하는 함수는 절대 안전하지 않다
- 최소한의 방어 코드:
int& operator[](int index) {
if (index < 0 || index >= size) {
throw std::out_of_range("Index out of range");
}
return ptr[index];
}
또는 로그 출력, 기본값 반환 등으로 방어 가능.
📘 메모리는 연속적이다. 그런데 왜 경계 검사가 필요할까?
✅ 메모리 구조와 접근
- 힙 메모리는 선형(쭉 이어진 형태) 으로 존재하며, 우리가 선언한 배열은 그 중 일부를 할당받은 것일 뿐이다.
- Array a1{5,10}; 은 10칸짜리 배열을 힙 메모리 중 일부에서 확보한 것이다.
- 그러나 컴퓨터 입장에서는 -1번 인덱스든 15번 인덱스든 물리적으로 접근 자체는 가능하다.
❗ 왜 위험한가?
접근 인덱스 | 문제 여부 | 설명 |
a1[2] | ✅ 정상 | 우리가 직접 할당한 영역 (0~9번 인덱스) |
a1[-1] | ❌ 위험 | 우리가 할당하지 않은 메모리이지만, 연속된 공간이라 접근은 됨 |
a1[15] | ❌ 위험 | 범위 밖이지만 쓰레기 값 출력 가능, 혹은 치명적인 충돌 발생 가능 |
📌 결론: 메모리는 존재하지만, 그 공간은 내 것이 아니다.
- ptr[-1], ptr[15] 모두 컴파일러는 오류를 내지 않지만,
운영체제가 해당 주소를 내게 허락한 공간인지 여부는 알 수 없다. - 따라서 정확히 내가 할당한 범위 내에서만 접근하겠다는 규칙이 "경계 검사"이다.
✅ 경계 검사란?
int& operator[](int index) {
if (index < 0 || index >= size) {
throw std::out_of_range("잘못된 인덱스 접근!");
}
return ptr[index];
}
이렇게 하면 잘못된 접근 시 예외를 발생시켜 프로그램을 안전하게 보호할 수 있다.
ChatGPT 로 만든 예제
📄 예제: Basic_array_problem.cpp
🎯 설명
경계 검사의 필요성
class BasicArray {
private:
int arr[5]; // 고정 크기 배열
public:
BasicArray() {
for (int i = 0; i < 5; ++i)
arr[i] = i * 10; // [0, 10, 20, 30, 40]
}
// 첨자 연산자 오버로딩 (경계 검사 없음)
int& operator[](int index) {
return arr[index]; // 경계 체크 없이 직접 접근
}
void PrintAll() const {
for (int i = 0; i < 5; ++i)
std::cout << arr[i] << " ";
std::cout << std::endl;
}
};
int main() {
BasicArray ba;
ba.PrintAll(); // 정상 출력: 0 10 20 30 40
ba[2] = 999; // 정상 동작
cout << ba[2] << endl; // 999 출력
ba[10] = 1234; // ❌ 경계 밖 접근, 런타임 오류 또는 이상 동작 가능
cout << ba[10] << endl; // ❌ 위험한 접근
return 0;
}
이 코드는 operator[] 를 오버로딩했지만 경계 검사를 하지 않는다.
ba[10] = 1234; 와 같이 잘못된 접근을 해도 컴파일은 통과하지만 런타임 오류나 이상 동작을 유발할 수 있다.
◆ 첨자 연산자 오버로딩
● 첨자 연산자 오버로딩 예제를 위한 클래스 정의
● Point의 동적 할당된 배열과 그 크기를 멤버 함수로 갖는 PointArr 클래스 정의
class PointArr {
private:
Point* arr;
int arr_len;
public:
PointArr(int len)
:arr_len{ len }
{
arr = new Point[len];
}
int get_arr_len() const { return arr_len; }
~PointArr() { delete[] arr; }
};
📘 첨자 연산자 오버로딩 (operator[]) 예제와 구현
✅ 목적
Array a1{5, 10};
std::cout << a1[0] << std::endl;
이처럼 클래스 객체를 배열처럼 사용하려면 operator[] 연산자를 멤버 함수로 오버로딩해야 한다.
✅ 기본 구현
int operator[](int index) {
return ptr[index]; // 경계 검사 없음
}
- a1 로 컴파일러가 해석
- 클래스 내부에서 ptr[index]에 바로 접근
- 하지만 경계 검사 없이 사용하면 쓰레기 값 또는 메모리 오류 발생
✅ 경계 검사 추가한 구현
int operator[](int index) {
if (index < 0 || index >= size) {
std::cout << "Out of Range!" << std::endl;
exit(1); // 비정상 종료
}
return ptr[index];
}
- 인덱스가 범위를 벗어나면 강제 종료 (exit(1))
- 정상적인 index인 경우에만 ptr[index] 반환
❓ 왜 처음엔 void 반환형으로 만들었을까?
교수님이 처음 void operator[](int) 로 정의한 이유는 다음과 같다:
- 함수 구조만 확인하고자 할 때 사용
- 실제 반환할 값이 없고, 단순히 컴파일 확인용 "틀" 만들기 목적
- 이후 로직이 정리되면 진짜 목적에 맞게 int or 참조형으로 수정
❓ 무조건 참조형으로 반환해야 하는 건가?
아니다. 아래 기준으로 나뉜다 :
상황 | 반환형 |
읽기 전용 (값 복사만) | int, const T 등 |
읽기 + 쓰기 가능 (a[2] = 5) | int& 등 참조형 |
📌 즉, 수정도 가능하게 하려면 참조형(&) 반환이 필요하다.
❓ exit(1) 은 어떤 의미?
- exit(n) 은 프로그램을 강제 종료시키는 함수
- n == 0 → 정상 종료
- n != 0 → 비정상 종료 (보통 오류가 있었음을 나타냄)
예:
exit(1); // 오류 종료
exit(100); // 특정 코드 번호로 종료
exit(-1); // 관례적으로 에러 종료
❓ 예외 처리 방식도 궁금해!
📌 C++ 예외 처리 기초
#include <stdexcept>
int& operator[](int index) {
if (index < 0 || index >= size)
throw std::out_of_range("Index out of range!");
return ptr[index];
}
- throw: 예외 객체를 던진다
- std::out_of_range: 표준 라이브러리에 있는 예외 타입
- try/catch 구문으로 잡아낼 수 있음
try {
std::cout << a1[15];
} catch (std::out_of_range& e) {
std::cout << "에러 발생: " << e.what() << std::endl;
}
exit()은 프로그램을 종료, throw는 프로그램에 예외 상황을 알림
→ 실무에서는 throw를 더 선호
🧠 요약
- operator[] 는 반드시 멤버 함수로 정의
- 반환형은 상황에 따라 값 or 참조형
- 경계 검사 필수! (직접 if 검사 또는 throw)
- exit()은 즉시 종료, throw는 예외 처리
중요한 Part이다.★★★★★
◆ 첨자 연산자 오버로딩
● 첨자 연산자 오버로딩의 구현
Point& operator[](int idx) // 참조형으로 반환되는 이유는?
{
if (idx < 0 || idx >= arr_len)
{
std::cout << "Array out of bound!" << std::endl;
exit(1);
}
}
● 참조형으로 반환해야만 arr [0] = Point {10,20}과 같이 배열 내부 데이터에 접근 가능
☞ 참조형으로 반환하지 않으면 복사된 값이 반환된다는 것을 기억해야 한다.
📘 첨자 연산자 오버로딩과 참조형 반환의 필요성
✅ 문제 상황
a1[0] = 10;
이 문장이 의미하는 바는?
a1.operator = 10;
✅ 반환형이 int (값 복사인 경우)
int operator[](int idx) {
return ptr[idx]; // 값을 복사해 반환
}
- a1[0] = 10; → 5 = 10; 과 같은 구조가 되어버림
- 값 복사는 수정할 수 없는 임시 값
- 그래서 컴파일 에러 발생:
"식이 수정할 수 있는 lvalue 여야 합니다."
✅ 해결책: 참조형 반환
int& operator[](int idx) {
return ptr[idx]; // 참조로 반환
}
- 이제 a1[0] = 10; 은
→ *(ptr + 0) = 10; 처럼 동작 - Heap 메모리의 실제 값을 수정할 수 있게 된다
💬 결론: 값을 수정할 수 있게 하려면 참조형이 필수
반환형 설명 a[0] = 10; 가능 여부
반환형 | 설명 | a[0] = 10; 가능 여부 |
int | 값 복사, 읽기만 가능 | ❌ 불가능 (컴파일 에러) |
int& | 참조 반환, 원본 데이터 접근 가능 | ✅ 가능 |
❓ 그럼 항상 참조형으로 반환하는 게 더 좋은 걸까?
✅ 장점 (참조형 T&)
- 배열처럼 읽기/쓰기 모두 가능
- 대입(a[i] = x), 증가(a[i]++) 등 다양한 연산 호환
- 효율성 ↑ (복사 생략)
⚠️ 단점
- 잘못된 참조(범위 밖, dangling reference)로 이어질 수 있음
- 값 자체만 사용하고자 할 땐 굳이 참조형이 아닐 수도 있음
💡 정리: 용도에 따라 다르다
상황 | 반환형 추천 |
읽기 전용 (std::cout << a[i];) | int 또는 const T& |
읽기 + 쓰기 가능해야 함 | T& (참조형) |
🔑 요약 정리
- a[i] = 10; 을 허용하려면 반드시 T& 참조형으로 반환
- int 로 반환하면 값을 복사해서 넘기므로 수정 불가
- 실무나 라이브러리 설계 시에는 보통 쓰기 가능 버전 + 읽기 전용 버전 둘 다 오버로딩
중요한 Part이다.★★★★★
◆ 첨자 연산자 오버로딩
● 첨자 연산자 오버로딩의 구현
Point operator[](int idx) const // const 오버로딩 하는 이유?
{
if (idx < 0 || idx >= arr_len)
{
std::cout << "Array out of bound!" << std::endl;
exit(1);
}
}
● const PointArr 사용에 있어서 데이터에 접근 가능해야하기 때문이다.
📘 첨자 연산자 오버로딩: const 버전과 비-const 버전의 필요성
✅ 문제 상황
const Array a1{5, 10};
std::cout << a1[0];
- a1은 const 객체이기 때문에
→ a1.operator 호출 시 반드시 const 멤버 함수만 호출 가능
📌 오류가 나는 이유:
- 기존 함수 int& operator[](int index)는 const 객체에서 호출 불가
✅ 해결 방법
함수를 두 개 오버로딩한다:
int& operator[](int index) {
// 쓰기 가능, 비-const 객체 전용
return ptr[index];
}
int operator[](int index) const {
// 읽기 전용, const 객체에서 호출 가능
return ptr[index]; // 값 복사 (쓰기 불가)
}
❓ 왜 하나는 참조형 & 하나는 값 반환인가?
함수 시그니처 | 쓰기 가능 여부 | const 객체 호출 가능 여부 | 주 용도 |
int& operator[](int) | ✅ 가능 | ❌ 불가 | 쓰기용 |
int operator[](int) const | ❌ 불가 | ✅ 가능 | 읽기 전용 |
❗ int&를 const 함수에서 반환하면 수정 가능성이 생기므로 위험
❗ 컴파일러가 선택하는 기준
a1[0] = 10; // a1이 const → const 멤버 함수 호출
a2[0] = 10; // a2가 일반 객체 → 쓰기 가능한 함수 호출
C++은 객체의 const 여부를 기준으로 적절한 operator[]를 선택함
❓ “C++은 반환형/인자가 다르면 같은 이름의 함수 여러 개 만들 수 있나?”
✅ 맞다!
- C++은 함수 이름이 같아도 인자 리스트(시그니처) 가 다르면 오버로딩이 가능하다
- 다만, 반환형만 다르고 인자는 동일한 경우는 오버로딩 불가!
int func(int x); // ✅
double func(int x); // ❌ 반환형만 다르면 불가
📌 즉:
조건 | C++ 함수 오버로딩 가능 여부 |
인자 타입/개수 다름 | ✅ 가능 |
반환형만 다름 | ❌ 불가 |
C#도 동일한 방식이다. 함수 이름은 같아도 인자의 타입이나 개수로 오버로딩을 구분하지, 반환형만 보고 구분하지 않는다.
🔑 요약 정리
- int& operator[](int) → 쓰기 가능, 일반 객체용
- int operator[](int) const → 읽기 전용, const 객체용
- C++은 인자가 다르면 함수 이름이 같아도 오버로딩 가능
- 반환형만 다르면 불가능!
https://youtu.be/G09ag57LmUE?si=mvJfIHLMnxA8U8iI
'C++ > C++ : Study' 카테고리의 다른 글
9. Generic Programming과 템플릿 (1) - 개요 (0) | 2025.06.24 |
---|---|
8. 연산자 오버로딩 (10) - 첨자 연산자 오버로딩 (1) | 2025.06.22 |
8. 연산자 오버로딩 (8) - 대입 연산자 , 깊은 복사 (3) | 2025.06.21 |
8. 연산자 오버로딩 (7) - 대입 연산자 , 얕은 복사 (0) | 2025.06.20 |
8. 연산자 오버로딩 (6) - 스트림 삽입 및 추출 연산자 오버로딩 (1) | 2025.06.18 |