0. C와 C++, C#
① C는 절차지향언어로, 1972년에 만들어져 아주 오래되었기에 리소스가 많이 없을때(저사양) 유용하다. C++은 C의 superset이 되고자 만들어졌으며 C와 코드 구조가 유사하다. 다만 C는 함수를 이용하고 C++은 객체를 이용한다.
함수와 객체를 이용하는 것은 출력에서도 차이가 보인다.
C는 printf("%d", var);로 함수를 사용하므로 괄호가 존재한다. 하지만 C++은 cout << var;로 괄호가 없는 객체이다.
또한 C는 절차지향, C++과 C#은 객체지향 언어이다.
0-1) 절차지향과 객체지향
프로그래밍의 방법론이라고 생각할 수 있다. 절차지향은 각 단계를 순차적으로 수행하며 실행속도는 빠를 수 있다. 하지만 프로그램의 규모가 커지며 유지보수를 하기에 어렵다는 단점이 존재한다. 따라서 각 기능을 모듈화하고 같은 기능을 재사용함으로써 유지보수에 용이하도록 객체지향 프로그래밍이 설계되었다. 즉 Class라는 개념을 이용하여 어떠한 객체(Object)를 지향(Oriented) 하는 프로그래밍으로 줄여서 OOP라고 한다.
0-2) 객체지향 특징
① 캡슐화
: 데이터와 코드의 형태를 외부가 알 수 없도록 데이터 구조, 역할, 기능을 하나의 캡슐 형태로 만드는 것이다.
* 프렌드(friend)
친구는 private한 영역을 공유할 수 있다. 즉 A 클래스의 private 멤버를 friend인 B가 사용할 수 있는 것.
② 상속
: 상위 개체(부모)의 속성을 하위 개체(자식)에게 물려주어 코드의 중복 작성을 방지하고 재사용을 통해 시간과 비용을 줄일 수 있다.
③ 다형성
: 같은 이름의 메소드가 조금의 차이(매개 변수나 데이터 타입 등)에 따라 다르게 구현되는 것이다. 똑같이 걷는 Walk() 함수가 있다면, 사람은 두 다리로 걷지만, 강아지는 네 발로 걸어야 한다. 이름을 따로 구현한다면 pigWalk(), personWalk(), dogWalk() ... 등 수 많은 이름이 사용되어야 할 것이다. 즉, 이름의 낭비를 막기 위해 오버로딩(overloading, 함수 중복 정의)을 사용하여 다형성을 확보할 수 있다. 또한 부모에서 상속된 함수를 자신의 특성에 맞게 수정하여 사용하는 오버라이딩(overriding, 함수 재정의 - 상속의 특성)도 있다.
오버로딩
① 메소드 이름이 같아야 한다.
② 파라미터 개수가 다르거나, 자료형이 달라야 한다.
오버라이딩
① 상위 클래스에 오버라이드 하고자 하는 메소드가 존재해야한다.
② 메소드 이름, 파라미터, 리턴형이 같아야 한다.
③ 상위 메소드와 동일하거나 내용이 추가되어야 한다.
④ 추상화
: 객체에서 공통으로 사용되는 속성을 이름을 주어 추상적으로 선언해두는 것. 즉 Class로 묶어두는 것을 의미한다. 예를들어 몬스터 객체를 만든다고 해보자.
monster_name[50] = { 달팽이, 슬라임, ... }
monster_hp[50] = { 50, 70, ... } 등으로 밑도 끝도 없는 코딩을 경험하게 될 것이다..
이 때 Class를 이용하면
class Monster {
private string name;
private int hp;
private int exp;
}
다음과 같이 표현할 수 있다. Monster 달팽이 = new Monster("달팽이", 15, 3);을 통해서 단 한 줄로 달팽이라는 몬스터 객체를 생성할 수 있는 것이다.
1. 참조(Reference)와 포인터(Pointer)
포인터는 대부분 별도의 변수, 참조는 대상의 별칭
포인터는 대상의 주소를 알 수 있고, 실제 값을 얻으려면 역참조한다(*ptr)
포인터 사용은 call by address라고 한다. 주소값 자체에 접근하여 변수를 얻어오기 때문이다.
레퍼런스는 call by reference라고 하며 변수에 붙은 별칭을 이용하여 주소값에 접근하여 변수를 얻어온다.
포인터는 또 하나의 변수로 다른 변수의 주솟값을 가리키게 두는 것이고, 레퍼런스는 그 변수 자체에 별칭을 붙여주기 때문에 포인터의 주소와 변수의 주소는 다르지만, 레퍼런스와 변수의 주소는 같을 것이다.
포인터는 NULL 허용, 레퍼런스는 NULL 허용X
포인터+1을 하면 자료형 만큼 이동한다.
1-1) 깊은 복사와 얕은 복사
깊은 복사는 변수 값마저 복사하는 깊은(Deep) 복사이며, 얕은 복사는 그 주소를 복사하는 얕은(Shallow) 복사이다.
얕은 복사는 디폴트 복사 생성자에 의해 주소를 복사하므로, 복사된 멤버 변수의 포인터가 원본의 주소와 같은 곳을 가리키고 있으므로, 소멸자가 두 번 호출되면 오류가 일어날 수 있다. 따라서 깊은 복사를 사용한다.
https://bblackscene21.tistory.com/6
2. 스마트 포인터
Modern C++을 공부하면서 알게된 것인데, 요즘의 포인터는 매우 똑똑해졌다. 목적은 동적으로 할당된 객체에 대한 실제 포인터를 캡슐화하고 사용을 추적하기 위해 사용한다. 조금 더 간단하게 말하면 delete문을 쓰지 않아도 적절하게 메모리를 해제하여 메모리 누수를 방지하기 위한 것이다.
2-1) unique_ptr
make_unique();
하나의 스마트 포인터만 특정 개체를 소유할 수 있다(unique).
move() 멤버 함수로 소유권을 이전할 수 있다.
다만 복사는 불가능하며 해당 객체의 unique_ptr만이 해당 객체를 삭제할 수 있다(.reset()).
2-2) shared_ptr
make_shared(); -> 동적 할당이 한번만 일어나므로 두번 일어나는 std::shared_ptr<A>보다 비용이 적다.
하나의 특정 개체를 참조하는 스마트 포인터가 총 몇 개 인지 참조하는 스마트 포인터이며, 참조 횟수(reference count)가 0이면 메모리를 해제한다. 여러 개의 shared_ptr이 같은 객체를 가리킬 수 있다.
2-3) weak_ptr
make_weak();
shared_ptr의 순환 참조를 제거하기 위해서 사용된다. 순환 참조란 서로를 가리키는 shared_ptr이 있다면 참조 횟수가 0이 될 수 없는 것인데, weak_ptr은 refernce counting에 들어가지 않으면서(약한 참조) shared_ptr의 역할을 하기 때문에 순환 참조를 막을 수 있다.
3. 반복자(Iterator)
자료구조 액세스 시에 사용하는 포인터와 비슷한 객체다. 컨테이너 내부의 요소 값을 확인할 수 있다(참조).
C++를 사용하면 vector나 deque STL을 사용하게 되는 경우가 있는데, 이 때 반복자를 이용하여 각 원소에 접근하고 연산을 수행할 수 있다.
이름이 왜 반복자(iterator) 일까? 하는 고민이 있었는데 아래의 글이 있었다.
1999년에 C++ How to를 번역 하신 분도 반복자라는 이름보다 훑개라는 말을 더 맘에 들어하시는 듯 하다..
반복자의 종류는 다음과 같다.
① 입력 반복자: 현재 위치에서 읽기
② 출력 반복자: 현재 위치에서 쓰기
③ 순방향 반복자: 순방향(++) 가능, 읽기/쓰기 가능
④ 양방향 반복자: 순방향에 역방향(--)까지 가능, 읽기/쓰기 가능
⑤ 임의 접근 반복자: 읽기/쓰기, 양방향, [], +=, -=, +, - 가능
아직까지 이해가 안 갈 수 있다. 그래서 왜 쓰는걸까? 그건 자료구조의 특성에 있다. 벡터나 배열처럼 순차자료구조여서 물리적으로 저장되는 데이터의 위치가 인접하다면 인덱스를 통해서 데이터에 접근이 가능할 것이다. 하지만 map, set, 연결리스트처럼 연결자료구조이거나 데이터의 위치가 물리적으로 아무 관련이 없다면 인덱스를 사용할 수가 없게 된다. 예를들어 vector[5]는 0에서부터 물리적주소를 5번 옆으로 옮기면 접근할 수 있지만, 연결리스트의 3번째는 0에서 부터 물리적으로 3번 옆으로 옮긴다면 엉뚱한 값이 나올 것이다. 그래서 사용하는 것이 반복자이다.
4) 상속
상속을 알아보기 전에 Class에 대해 조금 더 짚고가자면, Class안에는 private, public, protected로 접근 권한에 따라 나뉘어진 멤버가 존재한다.
public | 모두 접근 가능 |
protected | 상속 관계의 클래스만 접근 가능 |
private | 해당 클래스 내부에서만 접근 가능 |
구조체와 클래스의 눈에 띄는 차이라 하면 이 부분인데, 클래스는 기본적으로 private이고, 구조체는 public이다.
* explicit(명시적): 생성자의 암시적 변환을 막는다. 컴파일러는 요구사항이 아닌 자료형을 받아도, 요구 사항의 자료형으로 암시적으로 변환할 수 있는데, 이를 막음으로써 프로그래머의 명시된 요구사항을 그대로 수행하도록 한다. 생성자에 string 값을 받아야하는데 int형을 받았을 경우, 전혀 다른 값을 가진 객체가 생성될 수 있고, 이를 프로그래머가 놓칠 수 있는 경우를 방지한다.
* mutable: const 함수가 조작할 수 있는 변수로 선언하는 것이다. const 함수는 멤버 변수를 조작하지 않는다는 것을 보장하는 것인데, 예외를 두기위해 mutable을 사용한다.
Class에 대해 어느정도 감이 왔다면 다음의 코드를 보자. (생략된 코드이다)
class Human {
private:
int age;
char name[10];
public:
Human(int age, char* name):
}
부모 클래스인 Human
class Student : public Human {
private:
char grade[10];
public:
Student(int age, char* name, char* grade) : Human(age, name)
}
자식 클래스의 경우에는 : public 부모 클래스를 붙여주어 상속받을 수 있다. 생성자의 경우에도 Human이라는 틀을 만들어두고 거기에 학생이라는 Student Class를 첨가해준다는 느낌으로 : 제한자 부모클래스(부모 생성 시 파라미터)로 정의할 수 있다.
* 제한자(public) 부분에 protected를 넣어주면 모든 public이 protected 변수로 전달된다.
그렇다면 이렇게 사용하는 의미가 무엇일까? Human이라는 틀은 공통적으로 쓰지만 Student가 있고, Teacher가 있다. Student는 grade라는 학년이 필요하지만, Teacher는 grade 대신 다른 변수가 필요하다는 가정을 해보자. 공통적으로 사용되는 name과 age에 대한 변수와, 변수를 참조(get)하고 수정(set)하는 함수를 중복 작성해야한다. 이 때 상속을 통해 부모 클래스의 멤버 변수를 사용할 수 있게되며, 접근도 가능하다.
* final과 override
컴파일러가 오버라이딩 한다는 것을 알 수 있도록 명시적으로 override를 붙여줄 수 있다. 붙여주지 않으면 문법적으로 오류가 없기 때문에 컴파일러가 오류를 알지 못하고 런타임 시 오류가 날 수 있다. final은 상속을 막아주는 키워드다. 개발자가 상속을 원치 않는 클래스인데 실수할 수 있는 상황을 방지해준다.
4-1) 업캐스팅과 다운캐스팅
Human* human = new Student();
다음과 같이 human이라는 부모 클래스 포인터에 자식인 Student 객체로 동적할당을 하게 되는 경우를 업캐스팅(Student->Human)이라고 한다. C++은 이 경우에 오버라이딩된 함수를 호출하면 Human(부모)의 함수가 호출된다. 업캐스팅은 하나의 부모 클래스에 여러개의 자식 클래스가 있을 때 유용한데, 하나의 업캐스팅 포인터 변수를 선언해두고, 그 자식들을 호출해야할 때 for문이나 map등을 이용해 업캐스팅하며 주소 사용의 효율성을 높일 수 있기 때문이다.
업 캐스팅과 반대의 경우를 다운캐스팅(Human->Student) 이라고 하는데, 명시적인 방법을 사용해야 한다. 정적 캐스팅, 동적 캐스팅 등을 이용하는데, 정적 캐스팅은 어떤 형으로든 변환이 가능하며 그만큼 에러의 위험이 높다. 동적 캐스팅은 안전하지만(실패하면 nullptr 반환) 다형성을 가지는(virtual) 경우에만 적용이 가능하다.
* 캐스팅: 형 변환
* const_cast: const 특성을 없애주는 형 변환
5) 가상함수(virtual)
C++의 다형성을 책임지는 키워드로 virtual가 있다. 한마디로 해당 키워드가 붙여진 함수는 나중에 재정의하겠다. 라고 명시해두는 것이다.
※ 파생 클래스에서 재정의된 멤버 함수도 가상함수가 된다.
5-1) 동적 바인딩(dynamic binding)
binding이라는 의미가 무엇일까... 묶는다는 의미인데, 정적 바인딩과 동적 바인딩이 있다. C++에서 정적이라 하면 보통 컴파일 타임, 동적이라 하면 런타임에 일어나는 것인데..
binding은 컴퓨터 메모리 주소에 어떤 정보들을 묶어둔다고 생각하면 될까? (적절한 비유가 아닐지도 모르지만). 그렇다면 정적 바인딩은 이름에서 유추할 수 있듯, 컴파일 시 소스코드에 있는 정보들을 어떤 메모리들에 묶어두는 것이다. 즉 적절한 때에 사용할 수 있도록 속성과 주소를 합쳐서 집어넣어 두는 것이라고 이해할 수 있겠다.
예를 들어 int a = 30; 이라는 것도 정적 바인딩의 예시가 될 것이다.
동적 바인딩은 마찬가지로 이름에서 유추해보면 런타임 동안에 변수 속성과 주소를 어떤 메모리에 묶어두는 것이다. 가상함수 카테고리에 이 항목을 넣어둔 것은, 컴파일러 입장에서 코드를 쭉 읽어가며 일을 하고 있는데, virtual라고 나중에 정의할게~ 라고 해두면 실행 시킬 때 어디로 가야할 지 모를 것이다. 그 밑에 또 수만개의 줄이 있는데, 이것을 재정의 한 함수를 찾고 있어야 하는 건 이상한 일 일 것이다.
가상함수에 대해서 구글링하다가 본 stackoverflow의 한 글인데, 변수 c처럼 타입이 분명한 경우에는(레퍼런스나 포인터가 아닌) 정적 바인딩을 한다.
기초 클래스의 소멸자는 반드시 가상으로 선언되어야 한다. 기초 클래스 Animal과 그 파생 클래스 Pig가 있다면, 생성될 때는 Animal -> Pig 순이지만 해제될 때는 메모리 누수가 발생할 수 있다.
- 부모형 포인터로 자식을 할당받았으면, 부모의 소멸자가 먼저 호출되고, 자식 클래스의 소멸자가 호출되지 않아 메모리 누수가 발생한다.
- 자식형 포인터로 자식을 할당받았으면, 자식의 소멸자가 먼저 호출되고, 자식 클래스의 소멸자가 호출되어 정상적으로 해제된다.
- virtual 소멸자로 선언하면, 자식의 소멸자가 먼저 호출되고, 자식 클래스의 소멸자가 호출되어 정상적으로 해제된다.
* 인터페이스(Interface) : 쉽게 말하면 틀로, 추상적으로 구현해 둔 형식이다. Java를 사용하다보면 interface 파일을 하나 만들어 틀을 짜두고, 구현부를 다른 파일로 분할하여 사용하는 것을 본 적이 있을 것이다. C++에서는 구조체 또는 클래스와 virtual을 이용해서 인터페이스를 정의하여 프로젝트에 사용하기도 한다. 반드시 재정의하겠다는 함수를 순수 가상 함수로, virtual 함수 =0으로 표시한다. 이러한 함수를 포함하는 클래스를 추상 클래스(abstract class)라고 한다.
아무래도 틀을 짜두면 코드의 시야가 넓어진다고 해야할까? 조금 더 멀리서 큰 그림을 그릴 수 있게 된다.
5-2) 다이아몬드 상속과 가상 상속
A -> B, C -> D 상속일 경우 B,C에 A에서 상속받은 공통 멤버가 있는데, D가 B,C를 상속받으니 중복 문제가 발생한다. 이를 다이아몬드 상속이라하는데, 상속 시에 제한자 옆에 virtual 키워드를 붙여 상속받게 되면 컴파일러가 A를 단 한번만 포함한다.
6) 동적할당과 정적할당
동적할당은 런타임, 정적할당은 컴파일타임에 일어난다. 동적 할당은 프로그램 실행 중에 메모리 크기를 동적(dynamic)으로 결정하는 것이고(Heap 영역), 정적할당은 컴파일할 때 고정된 메모리 크기를 할당하는 것이다(Stack 영역).
* 컴파일: 사람의 언어 -> 컴퓨터 언어
int* ptr_int = new int;
*ptr_int = 100;
7) 정적 또는 동적 링크
정적 링크 (라이브러리)는 컴파일 타임에, 동적은 런타임에 연결된다. 정적은 lib 파일로 저장되며 컴파일할 때 한번에 포함되는 반면에, 동적은 dll 파일로, 프로그램 실행시 필요할 때 참조하여 사용한다.
8) 템플릿
같은 로직의 코드인데, 자료형만 다른 경우 template을 사용할 수 있다. template <typename A, typename B> 등으로 선언할 수 있다.
*템플릿 특수화: 일부 특정 경우에 다르게 동작하도록 지정하는 것이다.
*가변 길이 템플릿: typenmae...(함수 파라미터팩)으로 매개변수의 갯수를 가변시킬 수 있다.
9) 예외처리
throw, try, catch를 이용하여 예외 처리를 할 수 있다. 문법 상 아무 문제가 없더라도, 런타임에서 한정적인 자원을 이유로 또는 잘못된 접근 등을 이유로 오류가 발생할 수 있다. 이를 처리하는 것이 throw, try, catch 예외 처리이다.
try 안의 코드를 실행하던 중, 오류가 발생했을 경우 throw를 통해 오류 객체(레퍼런스 타입)를 던진다. 그럼 catch에서 받아 오류를 출력한다. 이 때 throw는 했는데, catch가 없다면? 함수를 호출한 영역으로 책임이 전가된다. 이를 스택 풀기(Stack Unwinding)라고 한다.
* 함수 호출 영역에서도 catch가 없다면 terminate 함수가 프로그램을 종료시킨다.
* exception은 std::exception을 상속받는 것이 좋다.
생성자에서 예외가 발생하면 소멸자가 호출되지 않으므로 catch에서 해제해주어야 한다.
* 인라인 어셈블리
최적화 등을 위해서 코드에서 어셈블리어를 사용해야할 때가 생긴다. 이 때 사용하는 것이 인라인 어셈블리로,
__asm__ 구문을 붙여 사용한다.
'CS지식' 카테고리의 다른 글
운영체제(OS, Operating System) (0) | 2022.08.08 |
---|---|
컴퓨터 네트워크 (0) | 2022.08.05 |
컴퓨터 그래픽스 (0) | 2022.08.02 |
자료구조와 STL (0) | 2022.06.23 |
[인공지능] Basic (0) | 2022.03.11 |
댓글