절차 지향 프로그래밍
모든 로직이 함수 기반으로 작성된다. 함수 호출 순서가 중요하며, 기능을 추가할 때 계속해서 함수를 늘려나가야 하므로 코드 확장성이 낮은 편이다.
객체지향 프로그래밍
객체를 중심으로 작성된다. 절차 지향과 달리 함수가 특정 데이터나 순서에 묶이지 않는다.
클래스
클래스는 객체를 생성하기 위한 설계도로 볼 수 있다. 설계도에는 속성(멤버 변수)과 기능(멤버 함수)를 정의하게 된다.
예를 들어, Animal 클래스에서 속성의 경우 이름, 친밀도 등이 될 수 있고 기능의 경우 밥먹기 잠자기 등이 될 수 있다.
class 클래스명
{
접근 제어 지시자:
멤버 변수, 멤버 함수 등 정의
}
클래스에 다음과 같이 멤버 변수와 함수를 정의할 수 있다. 객체를 생성할 때 메모리에는 멤버 변수만 올라가며, 멤버 함수의 경우 일반 함수와 유사하게 동작하게 된다.
class Animal
{
public:
// 멤버 함수
void Eat();
void Sleep();
public:
// 멤버 변수
int intimacy;
};
일반 함수와 멤버 함수
일반 함수는 클래스와 관련 없이 독립적으로 실행되었다. 만약 일반 함수를 특정 객체에 적용하기 위해서는 해당 객체의 주소를 넘겨서 사용하였다.
void Move(Animal* animal, int intimacy)
{
animaly->intimacy = intimacy;
}
int main()
{
Animal animal1;
Move(&animal, 10);
}
그렇다면 멤버 함수의 경우 어떻게 객체의 주소를 넘겨주게 될까? (코드상에서는 직접적으로 보이지 않는다!)
멤버 함수 또한 내부적으로는 포인터로 접근해서 수정한다! 메모리 상에서는 간접적으로 해당 클래스의 포인터(this) 로 자신의 주소를 넘겨준다. 즉, 함수(스택 프레임)에 직접적으로 주소를 명시해서 넘겨주지는 않고 간접적으로 자신의 주소를 넘겨 멤버 함수 내에서 수정할 수 있게 한다.
객체 생성
클래스를 사용해 객체를 생성하게 된다. 클래스는 객체로 생성해야 메모리에 올라가서 사용하게 된다! (클래스 자체는 따로 데이터로 잡혀서 메모리로 올라가는 개념은 아니다.)
하나의 클래스로 여러 객체를 찍어내는 구조이며, 각 객체는 고유한 메모리 공간을 차지하게 된다.
클래스이름 객체이름;
int main()
{
// 객체 생성
Animal animal1;
Animal animal2;
}
객체 멤버 함수 구현
멤버 함수를 클래스 외부에 정의할 때는 다음과 같은 형식을 따르게 된다.
반환값 클래스명::함수명() { ... }
여기서 '::'은 범위 지정 연산자로 컴파일러에게 해당 함수의 정의가 클래스에 있음을 알려주게 된다.
// ::을 통해 해당 클래스의 함수에 접근!
void Animal::Eat()
{
cout << "Eat" << endl;
}
void Animal::Sleep()
{
cout << "Sleep" << endl;
}
클래스 내부에서도 선언과 정의가 동시에 가능하다.
멤버 함수 내에서 멤버 변수에 접근할 때 클래스 포인터(this)를 통해 접근하여 수정하게 된다.
class Animal
{
public:
void Eat() { cout << "Eat!" << endl; }
void Init()
{
// this->_intimacy = 0;
// this는 Animal의 포인터이다. (*Animal)
_intimacy = 0;
}
public:
int _intimacy;
};
객체 멤버 변수 및 함수 접근
구조체와 동일하게 도트 연산자(.)로 멤버 변수에 접근하고 멤버 함수를 호출하게 된다.
(객체이름).멤버변수
(객체이름).멤버함수()
int main()
{
Animal animal1;
animal1.intimacy = 100; // 멤버 변수 접근
Animal animal2;
animal2.intimacy = 100;
// 멤버 함수 호출
animal1.Eat();
animal1.Sleep();
}
특별한 멤버 함수 (생성자, 소멸자)
클래스에 소속된 함수들을 멤버 함수라고 하였다. 이 중 굉장히 특별한 함수가 있는데 바로 시작과 끝을 알리는 함수인 생성자와 소멸자이다.
생성자와 소멸자를 통해 생명 주기 관리가 편리해지는 장점을 가지고 있으며 멤버 변수 초기화 등에 이용하게 된다.
생성자
생성자는 여러 개 존재(오버로딩)할 수 있으며 시작할 때 1회 호출된다.
생성자는 클래스와 동일한 이름으로 정의하게 된다.
클래스이름()
{
}
기본 생성자
인자를 따로 넘겨주지 않는 경우를 기본 생성자라고 부른다. 기본 생성자의 경우 아무 생성자도 정의하지 않으면 암시적으로 만들어진다. (단, 명시적으로 아무 생성자를 하나라도 만들게 되면 더 이상 자동으로 만들어지지 않는다.)
암시적 생성자 생성자를 명시적으로 만들지 않을 때 자동으로 컴파일러에 의해 만들어지는 생성자 |
class Animal
{
public:
// 기본 생성자 (인자가 없음)
Animal()
{
cout << "Animal 기본 생성자!" << endl;
}
};
복사 생성자
자신의 클래스 참조 타입을 인자로 받는 경우 복사 생성자라고 부른다. 일반적으로 똑같은 데이터를 가지는 객체를 만들 때 사용하며 어떤 다른 객체의 데이터를 복사해서 생성하겠다는 의미를 가진다.
일반적으로 다른 객체를 받아서 조작하는 것을 막고자 const와 함께 사용하는 편이다.
class Animal
{
public:
Animal(const Animal& animal)
{
_intimacy = animal._intimacy;
}
};
이전에는 클래스로 객체를 만든 후 데이터를 각각 넣어주었다. 한편, 처음 만든 객체의 데이터를 동일하게 가지는 다른 객체를 만들고 싶다고 가정해보자. 복사 생성자를 사용한다면 데이터를 각각 넣어주는 것이 아니라 앞의 객체의 데이터를 그대로 복사해서 다른 객체를 만들 수 있다!
int main()
{
Animal animal1(100);
dog1._intimacy = 0;
Animal dog2(dog1); // 이런식으로 다른 객체의 데이터로 멤버 변수를 초기화할 수 있다!
return 0;
}
default 복사 생성자
복사 생성자의 경우 따로 정의하지 않는다면 default 복사 생성자가 생성된다.
default 복사 생성자는 암시적 생성자로 따로 복사 생성자를 정의하지 않는다면 컴파일러가 자동으로 만들어준다. (기본 생성자와 달리 따로 복사 생성자 자체 정의하지 않는 경우 항상 암시적으로 만들어져있다. 즉, 다른 생성자들 만드는 것들과 연관 없다.)
명시적인 복사 생성자의 경우 부모나 멤버 클래스의 기본 생성자를 호출하는데 반해 컴파일러에 의해 추가되는 암시적인 복사 성생자는 부모나 멤버 클래스의 복사 생성자를 호출하며 기본적인 얕은 복사를 수행한다.
여기서 주의할점은 컴파일러가 만들어준 default 복사 생성자는 "얕은 복사"를 수행한다는 것이다!
얕은 복사란 메모리 영역의 값을 "그대로 복사"해온다. 즉, default 복사 생성자로 초기화된 객체는 복사한 객체가 가진 변수들을 모두 똑같이 가지게 된다.
깊은 복사란 원본 객체가 참조하는 대상까지 "새로 만들어 복사"하게 된다. 따라서 복사 대상인 원본 객체가 참조는 변수들 또한 새로 만들어서 복사해준다.
default 복사 생성자로 생성한 객체의 경우 얕은 복사를 사용하기 때문에 Owner*에 저장되는 주소가 모두 동일하게 잡히게 된다.
class Animal
{
public:
int _intimacy;
Owner* _owner;
}
int main()
{
Owner* owner = Owner();
Animal animal1;
animal.intimacy = 10;
Animal animal2(animal1); // 복사 생성자 (default)
Animal animal3(animal1); // 복사 생성자 (default)
}
이러한 얕은 복사의 경우 문제 상황을 야기할 수 있다.
1. 메모리 해제로 인한 문제
어떤 객체가 메모리 해제를 통해 해당 변수를 메모리에서 지워버린 경우를 생각해보자. 모든 객체가 동일한 주소를 가리키는데 다른 객체에서 해당 변수에 접근할 때 문제가 생길 수 있다.
소멸자에서 메모리 주소를 해제해주는 경우 여러번의 메모리 해제를 하는 문제 또한 발생할 수 있다.
2. 값을 공유하게 되는 문제
동일한 주소를 가리키고 있기 때문에 특정 객체에서 값을 변경하면 모든 객체의 값이 변경되게 된다. 각 객체들이 참조와 관련된 변수들도 모두 고유하게 들고 있다고 가정하고 코드를 작성하고 있었다면 문제가 되는 부분일 것이다!
따라서, 만약 주소 값을 그대로 복사하지 말아야 하는 경우 복사 생성자를 명시적으로 정의해줘야 한다. 주로 클래스 내부에 포인터나 참조가 있는 경우 항상 고려해봐야 하는 사항임을 명심하자!
타입 변환 생성자
기타 생성자 중 인자를 1개만 받는 것을 타입 변환 생성자라고 부른다.
class Animal
{
public:
int _intimacy;
public:
Animal(int intimacy) // 타입 변환 생성자
{
_intimacy = intimacy;
};
};
암시적 변환과 명시적 변환
|
int num = 1;
float f = (float)num; // 명시적
double d = num; // 암시적
클래스로 정의한 객체에 값을 대입하면 타입 변환 생성자에 의해 암시적 변환이 일어난다.
Animal animal1;
animal1 = 1; // 타입 변환 생성자 호출
임시 객체
함수 인자로 특정 클래스의 객체를 받는 경우를 살펴보자. 인수에 값을 넘기면 자동으로 형변환이 일어나며 임시 객체를 생성해주는 것을 알 수 있다. (이로 인해 객체 생성되고 소멸되는 과정 거치면서 메모리 낭비도 있다.)
void Test(Animal animal)
{
}
int main()
{
Test(1); // 정수를 바로 넘겨도 따로 에러가 나지 않는다!
Test(Animal(1)); // 이것과 동일하게 동작하게 되는 것이다!
Test(3.3);
}
결국 컴파일러가 암시적으로 변환해주는 부분들이 의도하지 않은 문제들을 야기할 수 있기 때문에 암시적 변환을 막을 수 있는 대안이 필요해보인다.
explicit
암시적 변환을 막기 위해서 explicit 키워드를 사용한다. explicit 키워드를 붙이게 되면 컴파일러에게 명시적 변환만 하겠다는 내용을 알려주게 된다. (변환 자체를 막는 것이 아닌 명시적 변환만 허용하게 되는 것이다!)
explicit Animal(int intimacy)
{
_intimacy = intimacy;
}
explicit을 붙이면 아까 아무 에러 없이 동작하던 Test(1)의 호출이 막힌 것을 볼 수 있다.
delete
생성자에 delete 키워드를 붙여 해당 생성자의 호출 자체를 막을 수 있다. (동적 할당의 delete와는 다른 키워드)
생성자() = delete;
class Animal
{
public:
int _intimacy;
public:
Animal(int) = delete; // int 인자로 받는 타입 변환 생성자 호출 막기
Animal(const Animal& animal) = delete; // 기본 복사 생성자 호출 막기
};
delete 이후 생성자를 호출하려고 하면 다음과 같이 에러가 뜨는 것을 확인할 수 있다.
소멸자
소멸자는 1개만 존재 가능하며 끝에 1회 호출된다. ~을 사용해 선언한다!
~클래스이름()
{
}
class Animal
{
public:
// 소멸자
~Animal()
{
}
};
'c++ > 기초' 카테고리의 다른 글
c++ 기초 - 동적 할당 (0) | 2024.02.05 |
---|---|
c++ 기초 - static 변수 및 함수, 정적 지역 객체 (0) | 2024.02.05 |
c++ 기초 - 배열 (array) (0) | 2024.02.04 |
c++ 기초 - 참조형 변수 (0) | 2024.02.04 |
c++ 기초 - 포인터 (0) | 2024.02.03 |