희래네 작은 집

간만에 C/C++를 본다면 기억해야 할 것 본문

C

간만에 C/C++를 본다면 기억해야 할 것

희래 2016. 4. 17. 01:05

1. Array declaration 시 크기 지정에 상수만 사용가능. 동적으로 입력 받은 변수뿐만 아니라 소스내에서 초기화한 변수도 안 된다. 이유는 compiler가 compile time에 프로그램에서 사용되는 모든 변수들에 대해 stack segment에 메모리를 할당하는데(어셈블리 코드를 보면 알 수 있음), 변수의 값은 compile time이 아니라 run time에 알 수 있다(변수에 값이 대입되는 시점은 run time). 따라서 변수를 크기로 받은 배열이 얼마만큼의 메모리를 차지해야 하는지 compile time때 compiler는 알 수가 없고, 에러를 낸다.

왜 프로그램이 변수들을 stack에 저장하는지는 시스템 프로그래밍, os, code segment, data segment, stack segment, process, thread 등의 키워드로 더 검색을.

동적으로 메모리 공간을 사용하고 싶을 때는 heap segment 영역을 사용하며, malloc으로(c++에서는 new) 할당받은 메모리는 heap 에 저장되므로, 이런 문제가 없다.

그런데 사실 이는 정확하게는 옛말이고 C99에서는

int arrSize = 10;
int myArr[arrSize] = {};

는 C99표준이다. 다만 개인적으로는 이식성을 위해 쓰지 않는게 좋다고 생각한다.


참고) 메모리 구조와 동적 할당 http://bboy6604.tistory.com/entry/%EB%A9%94%EB%AA%A8%EB%A6%AC-%EA%B5%AC%EC%A1%B0%EC%99%80-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%8F%99%EC%A0%81-%ED%95%A0%EB%8B%B9

참고) 메모리 관점에서 본 프로세스 http://mooneegee.blogspot.kr/2015/01/os-process.html

참고) 메모리 관점에서 본 쓰레드 http://mooneegee.blogspot.kr/2015/01/os-thread.html

참고) 스택 메모리 http://luckyyowu.tistory.com/6

참고) 메모리 세그먼트 http://egloos.zum.com/littletrue/v/4793286

참고) 어셈블리를 이용한 스택에 가변 배열 할당하기 http://blog.naver.com/flower_excel/20193987752

참고) 배열 변수 크기 할당 C99 표준 https://kldp.org/node/48411

참고) C 언어의 메모리 구조 http://dsnight.tistory.com/50


2. GC 의 기능을 갖춘 자바 이후의 현대적인 언어에 비해 C 언어는 사용자가 직접 allocation/deallocation을 해준다. 이 때 heap에 dynamic allocated 메모리는 명시적인 deallocation이 없으면 process가 terminate 될 때 까지 계속 메모리를 차지한다. 메모리 누수란 그런 것이다.


3. malloc시 실제로는 입력한 바이트만큼의 메모리를 할당하는 게 아니라 할당 정보(몇 바이트를 할당했는가)에 대한 추가적인 내용을 저장할 공간까지 같이 할당한다. 그래서 free 시 특별히 메모리의 크기를 입력해 주지 않아도 되는 것이다.


참고) free()는 어떻게 해제해야할 메모리 크기를 알까? http://sulife.tistory.com/entry/free-%ED%95%A8%EC%88%98%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EC%A0%9C%ED%95%A0-%EB%A9%94%EB%AA%A8%EB%A6%AC%EC%98%81%EC%97%AD%EC%9D%98-%ED%81%AC%EA%B8%B0%EB%A5%BC-%EC%95%8C%EA%B9%8C%EC%9A%94

참고) malloc, free, new, delete https://kldp.org/node/1073


4. 포인터와 동적할당시 헷갈리기 쉬운 것?

1번)

int* ip;
int i;

ip = &i;

까지 직관적이다. i라는 변수의 주소를 받는 ip 라는 int형 포인터 변수.

그럼 이건 어떨까.

2번)

int* pArr = (int*)malloc(10 * sizeof(int));

이 구문에서 malloc은 10 * sizeof(int) 바이트의 메모리를 os로부터 얻어낸 다음, int* 타입으로 쓰려고 캐스팅한 후 int* 타입의 pArr라는 변수에 집어넣었다. 자, (int*)malloc(10 * sizeof(int)) 은 int* 타입이다. 즉 1번) 에서 ip와 같다. 따라서 pArr의 값은 int형 변수의 주소를 가르키고 있다. 하지만 내가 pArr에 대입한 것은 한개의 int형 변수의 주소가 아니라 10개의 int가 저장될 수 있는 연속적인 공간인데? 라는 헷갈림이 발생할 수 있다. 이 때 C언어는 low level의 제어를 프로그래머에게 맡기는 언어임을 상기하자. malloc은 10개의 int가 저장될 수 있는 연속적인 공간을 만들고(만든다는 표현도 이상하다. 그냥 10개의 연속적인 빈 공간을 찾아낸 뒤 그 메모리를 사용중이라고 표시해서 데이터 overwrite가 안 되게끔 하는 것 뿐), 그 공간의 시작 주소를 리턴한다. 극단적으로 말해서, C언어는 이 시작 주소가 10개의 int를 저장할 수 있는 연속적인 메모리 할당이 된 공간인지 아니면 그냥 한개의 int변수가 할당된 주소인지 전혀 관심이 없다. 그런 논리적인 처리는 프로그래머가 논리적인 레이어 상에서 해결해줘야 하는 것이다. 따라서 pArr[1] 이든 *(pArr + 1) 이든 C언어는 그냥 해당 주소 + offset 한 주소의 값을 읽고 쓸 뿐이다. 그것이 논리적으로 옳고 그른가는 순전히 프로그래머가 판단해야 하는 부분. 이렇게 생각하면 왜 포인터 변수가 하나의 변수의 주소를 받을 수도 있고, 할당된 연속적인 메모리 주소를 받을 수 있는지도 헷갈리지 않는다. 어차피 C는 그 주소의 값 말고는 아는게 없거든.

마지막으로 이건 어떨까

3번)

int strCount = 5, strLength = 10;
char** strBucket;
strBucket = (char**)malloc(strCount * sizeof(char*));

for (int i = 0; i < strCount; i++) {
  strBucket[i] = (char*)malloc(strLength * sizeof(char));
}

여러 문자열들을 저장하는 strBucket이라는 char** 타입의 변수가 있다. strBucket에 strCount개 X sizeof(char*) 의 메모리 공간을 char** 으로 type casting한 메모리의 시작 주소를 넘겨주었다. 따라서 strBucket[1] 등을 통해서 각각의 char*에 접근 가능하다. 또한 이 각각의 strBucket[i] 라는 char* 들에 strLength X sizeof(char) 라는 메모리 공간을 char* type으로 넘겨 주었다. 따라서 각각의 strBucket[i] 들은 그 크기만큼 문자열을 저장할 수 있는 공간이 된다.

차근차근 생각하면 헷갈릴 것 없다. 포인터 변수는 뭐가 됐던간에 해당 타입의 시작 번지를 저장하는 변수다. 그게 연속된 공간인지, 한개의 공간인지, 그런건 C언어는 관심이 없다. 그걸 논리적으로 다루는 건 온전히 프로그래머의 몫이다. 컴파일러는 *pointerVal, *pointerVal[1], *(pointerVal + 1) 같은 연산에 대해서 그저 미리 정의된 행위를 할 뿐이다.


5. 배열과 포인터는 밀접한 연관이 있다. 그래서 거의 비슷하게 사용이 가능하다. 단, 그게 가능한 이유는 C언어가 low-level 제어가 가능하다는 특성과 배열이 메모리상에 일정공간에 연속적으로 할당된다는 특성 때문이지 배열 = 포인터 라서 그런게 아니다. 따라서 배열 = 포인터 라는 생각을 가지고 있다가 실수를 하면 안 될 것이다.

예)

int arr[20] = {};
int* arrP = arr;
printf("arrP의 크기는 80이라 기대했으나, 사실은 %d 였어.\n", sizeof(arrP)); // 답: 4
printf("반면 arr의 크기는 정확하게 %d이지.\n", sizeof(arr)); // 답: 80

arrP = (int*)malloc(20 * sizeof(int));
printf("물론 malloc으로도 크기는 %d이지. 배열이 아니라 포인터 변수니까.", sizeof(arrP)); // 답 : 4


또한 배열명은 포인터 '변수'가 아닌 '상수' 이다. 따라서 가리키는 주소를 변경할 수 없다. 이건 문자열을 다룰 때 무심코 실수하기 쉬운데,

예)

char str[10] = "first";
str = "second";

는 불가능한 것이다. 왜냐하면 본질적으로 첫 번째 문장과 두 번째 문장은 다른 기능을 하는 것이기 때문이다.

char str[10] = "first" 에서 = 은 구두점(punctuator), str = "second" 에서 = 은 연산자(operator)다.

따라서 첫 번째 문장에은 stack에 배열 메모리 공간을 할당하고 동시에 각각의 배열요소들을 'f', 'i', 'r' ... 식으로 초기화 하겠다는 뜻이다.

두 번째 문장에서는 배열의 시작주소를 "second\0" 라는 data segment의 문자열 상수의 시작 주소로 하겠다는 뜻이다. 하지만 포인터 상수는 l-value가 아니라, 대입 연산자를 사용하는 것이 오류인 것이다.


6.  배열은 배열명만 사용했을 때 포인터 상수로 사용할 수 있다. 사실 배열 선언을 제외하고는 대부분 포인터 상수로 사용된다고 보면 될 정도다.

예)

int arr[10] = { 0 };
printf("%d\n", arr[2]); // arr 라는 포인터 상수에 포인터 연산 [2] 를 하여 결과적으로 *(arr + 2) 라는 값을 읽는다.


그런데, 몇몇 특수한 경우(sizeof 연산자의 parameter 등)에는 배열 그 자체는 배열의 특성을 살리는 연산을 하므로 주의해야 한다.

예)

int arr[3][5] = {
  { 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0 },
  { 0, 0, 0, 0, 0 }
};
printf("%d\n", sizeof(arr)); // 답은 60이 나올 것이다. 이 때는 배열 그 자체로 평가된다.
printf("%d\n", sizeof(arr + 1)); // 답은 4가 나올 것이다. arr + 1 의 연산을 하는 순간 배열이 아니라 포인터 상수로 취급받는다.



7. 포인터 연산자 []는 내부적으로 *(.. + ..) 랑 완전히 동일하다. 즉 arr[i] == *(arr + i). 따라서 i[arr] 같은 표현도 가능한데 왜냐면 내부적으로는 *(i + arr) 이고 교환법칙이 성립해서 문제없는 코드가 된다.


8. C는 함수의 parameter로 배열을 전달하는 방법을 제공하지 않는다. 배열이 parameter면 포인터로 해석한다.

예)

void someFunc(int arr[5]); // 배열이 아니라 포인터가 전달된다
// 따라서 크기가 전달되지도 않고, 크기를 다른 parameter로 전달하지 않는 한 내부에서 알 수 있는 방법은 없으며
// 컴파일러는 상수 5를 완전히 무시한다


9. 문자열 상수(ex- printf("하하하") 에서 바로 이 "하하하" 가 문자열 상수)는 data segment에 전역변수, 정적변수 등과 함께 저장된다. 이유는 컴파일러가 c파일을 컴파일 해 기계어로 변환할 때 mov register, data 같은 명령을 수행하는 데 있어 data에 문자열과 같이 거대한 data chunk(배열)를 넣을 수 없기 때문이다. 따라서 data segment에 넣어두고 포인터를 사용해 연속적으로 메모리 복사한다. 따라서 문자열 상수는 정수 상수나 문자형 상수와는 달리 취급된다는 것을 기억.


10. const의 위치

T var1, var2;

// 1. const T 라는 타입에 대한 포인터 변수. 포인터 변수가 가리키는 대상이 const T 타입이기 때문에 상수고, 값을 변경할 수 없다.
// 포인터가 const 인 것은 아니기 때문에, 주소를 바꾸는 것은 문제 없다.
const T* pVar;
pVar = &var1;
pVar = &var2;
// *pVar = 1; err

// 2. T 타입의 포인터 변수 pVar 는 const라 가리키는 주소를 변경할 수 없다. 포인터 주소가 상수인 것인지 가르키는 주소의 값이 상수로 취급되는 것이 아니므로 변경할 수 있다.
// 이를 상수 포인터(constant pointer)라 부른다.
T* const pVar = &var1;
// pVar = &var2; err
*pVar = 1;

// 3. const T 타입의 포인터 변수 pVar는 const라 가르키는 주소를 변경할 수 없으며, 가리키는 주소의 값 또한 const 타입이라 변경할 수 없는 상수이다.
const T* const pVar = &var1;
// pVar = &var2; err
// *pVar = 1; err

const* T const* const* pppVar; 같은 것도 가능하다. const의 위치가 어디에 붙었는지만 잘 확인하면 논리적으로 해석이 가능하다. 예컨대 const* T const* const* pppVar 는 pppVar는 변경할 수 있지만 *pppVar, **pppVar, ***pppVar 는 변경할 수 없는 것이다.

즉 const 다음에 오는 대상이 상수라는 것만 확실하게 이해하면 된다.


11. member function 은 definition이 어디에 존재하느냐에 따라 inline 이냐 아니냐가 결정된다. struct 나 class 내부에 definition이 존재할 경우 inline, 외부에 type obj::method {...} 형태로 정의될 경우 inline이 아니다. 외부 함수도 inline으로 만들고 싶다면 inline type obj::method {...} 와 같이 정의해주면 된다. 하지만 내부에서 정의된 method의 경우에는 무조건 inline이다. 왜냐하면 class든 struct든 선언시에 컴파일러가 정의된 함수를 읽고 코드를 생성한다면, 해당 선언이 담겨있는 헤더가 여러번 호출될 때마다 함수가 중복정의되기 때문이다. 따라서 컴파일러는 함수가 호출될 때마다 선언부의 본체 코드를 호출부에 복붙할 수밖에 없으며 그러므로 내부 정의는 무조건 inline이다.


12. C++에서 struct의 개념이 확장되며 member function을 가질 수 있고 access restriction을 지정할 수 있게 되었다. 뿐만 아니라 struct 역시 constructor, destructor 를 가질 수 있고 inheritance 또한 가능하다. 게다가 구조체에 대한 문법(. 연산자, -> 연산자)은 클래스에도 똑같이 적용되고 클래스에 적용되는 모든 이론과 문법(다형성, 메소드 오버로딩등)은 구조체에도 적용된다. 사실상 C++에서 클래스라는 용어를 새로 도입한 것은 전통적인 struct와 확장된 개념의 struct를 명확하게 구분하기 위해서일 뿐, class와 struct는 완벽하게 똑같은 것으로 차이가 없다. 다만 명세에 있어서는 차이가 있는데 member에 대한 default access policy가 class는 private, struct는 public 이라는 것이다(C에서의 구조체와의 호환성을 생각하면 당연한 이야기이다). 즉 access restriction을 명시하지 않은 상태에서 struct의 member는 모두 public으로, class의 member는 모두 private으로 간주된다. 물론 access restriction을 명시하면 완벽하게 동일해진다.


13. 메모리 동적할당을 이용할 때 대상이 객체라면 constructor/destructor call 을 위해 malloc/free 가 아닌 new/delete 를 이용해야만 한다. 왜냐하면 new 와 delete는 constructor/destructor 를 call 하기 때문이다.


14. delete 는 NULL 포인터에 대해 아무 동작도 하지 않으므로 확인할 필요가 없다.


15. class의 복사 생성자의 parameter는 항상 reference 변수여야 한다. 즉 Obj::Obj(const Obj &obj) 와 같아야지 Obj::Obj(const Obj obj) 일수는 없다. 이유는 call by value의 특징을 잘 생각해보면 알 수 있다. Function call시 C/C++의 call by value는 argument로 입력받은 값을 '복사' 하여 parameter에 '대입'하는 프로세스를 수행한다. 그 과정에서 argument가 class라면, 그 argument를 '복사' 하기 위해 복사 생성자를 call하고, 그 call 한 복사 생성자에서는 또 '복사' 및 '대입' 을 위해 복사 생성자를 call 하고... 무한 루프에 빠지게 된다. 따라서 '복사 생성자를 위해 복사 생성자를 call 하는 상황'을 방지하기 위해 call by reference를 이용해야 하고 그러므로 복사 생성자의 parameter는 reference 변수여야 하는 것이다. 물론 포인터를 parameter로 받을 수 있겠지만 그럼 class를 하나의 타입으로 하는 일관된 디자인이 지켜지지 않는다. int i = j 와 같이 복사하지 int i = &j 와 같이 하지 않잖는가? 마찬가지로 Obj obj1 = obj2 와 같은 디자인적 일관성을 위해 reference(&)를 도입하고 사용한다.


16. Conversion constructor 와 conversion function에 대해서, 둘은 서로 같은 기능을 할 수 있는데 그럼 둘 다 정의되어 있다면 어떤것이 우선적으로 적용되나? 이에 대해 좀 더 자세하게 살펴보자.

class A;
class B;

class A {
...
};

class B {
...
};

void main() {
  B newB;
  A newA = newB; // ... 1)
}

이런 구문에 대해 생각해 보자. 컴파일러가 1) 번을 만났을 때 어떻게 행동하려 할까? 저 구문은 선언과 동시에 초기화를 하는 구문이다. 초기화는 결코 대입연산이 아니다. A의 constructor call을 newB 라는 정보를 가지고 하는 것이지, constructor call을 통해 만들어진 newA 라는 instance에 newB 라는 객체의 정보를 대입하는 것이 아니다. 저 문장은 묵시적으로 A newA(newB); 와 같이 해석된다. 따라서

#include <iostream>

using namespace std;

class A;
class B;

class A {
public:
  A();
  A(B bObj); // ... 2)
};

class B {
public:
  operator A(); // ... 3)
};

A::A() {
  cout << "A's base constructor" << endl;
}

A::A(B bObj) {
  cout << "A's conversion constructor with type B" << endl;
}

B::operator A() {
  A tempA;

  cout << "B's conversion function to A" << endl;

  return tempA;
}

void main() {
  B newB;
  A newA = newB; // ... 1)
}

1) 을 만난 컴파일러는 2) 와 3) 이 존재하더라도 고민없이 2) 를 찾아간다. 다시 말하지만 constructor call 을 하는 초기화 구문이지 대입 구문이 아니기 때문에. 이는 명확한 편이다. 그럼 이건 어떨까.

#include <iostream>

using namespace std;

class A;
class B;

class A {
public:
  A();
  A(B bObj); // ... 2)
};

class B {
public:
  operator A(); // ... 3)
};

A::A() {
  cout << "A's base constructor" << endl;
}

A::A(B bObj) {
  cout << "A's conversion constructor with type B" << endl;
}

B::operator A() {
  A tempA;

  cout << "B's conversion function to A" << endl;

  return tempA;
}

void main() {
  B newB;
  A newA;
  newA = newB; // ... 1)
}

1) 을 만난 컴파일러는 어떻게 행동하고 싶을까? 저 구문은 명백한 대입 구문이다. 그러니 얼핏 생각하기에는 별 고민없이 3) 을 찾아가면 될 것 같다. 하지만 컴파일 하면 에러가 난다.

more than one user-defined conversion from "B" to "const A" applies:

    function "B::operator A()"

    function "A::A(B bObj)"

이런 에러를 낸다. 2개 이상의 conversion 이 존재하여 컴파일러가 결정할 수가 없다는 것이다. 이게 표준인지는 모르겠다. 해당 컴파일러는 VS2013 의 기본 컴파일러다. 


17. Conversion constructor, Conversion function은 둘 다 존재의 이유가 있다.

Conversion constructor = 저놈을 나로 만드는 것

Conversion function = 내가 저놈이 되는 것

과 같은 방향으로 변환이 되는데, 서로 정확히 반대의 방향으로 동작한다. 이 때 대상이 컴파일러에 내장된 기본 타입이거나, 상용 라이브러리에서 제공되는 타입으로 소스 수정이 불가능하다면, 하나의 클래스에서 저놈을 나로 만들어 주기도 하고 내가 저놈이 되어주기도 하는 방법을 모두 정의할 필요가 있는 것이다. 다만 conversion function은 implicit type casting에 대해 어떤 안전장치도 마련할 수가 없다. Conversion constructor는 explicit 키워드를 통해 실수에 대해 좀 더 적극적으로 대처할 수 있으므로 두 가지 방법을 모두 사용할 수 있다면 conversion constructor를 사용하는 것이 좋다고 생각된다.

'C' 카테고리의 다른 글

OS X/Linux(Ubuntu) C, C++ 개발 환경 구축  (0) 2016.05.24
Comments