Search
Duplicate

C에서 시작하는 객체생활.

간단소개
C언어로 객체지향을 구현해보자!
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
C
자료구조
OOP
Scrap
태그
설계
구조체
동적할당
객체지향 프로그래밍
9 more properties

Subject

객체 지향이란 무엇인가?
C언어에서 객체지향을 구현해보자 - ft_vector

1. 객체지향(OOP)란 무엇인가?

1) 객체(Object)

객체지향은 프로그래밍을 공부하는 과정에서 만나는 큰 벽이라고 할 수 있다. 그렇기에 객체에 대해서 공부하기 위해서 이를 검색하면 흔히 ‘모든 것이 객체’라는 식의 추상적인 답변을 다수 접할 수 있다. 전혀 도움이 되지 않았다. 그렇다면, 대체 객체는 무엇인가?
나는 객체를 명확하게 구분될 수 있는 모든 대상이라 이해했다. 어떤 대상이 구분되기 위해서는 차이를 만드는 부분에 대한 비교가 필요하다. 그리고 우리는 이러한 비교의 달인들이다. 우리 인간은 무의식적으로 모든 대상을 끊임없이 비교하고, 그 결과로 얻어진 차이를 수집하여 대상을 분류(type)한다. 그리고 대상을 구성하는, 비교의 대상이 되는 각 항목을 속성(trait)이라 한다.
즉 인간은 대상을 분석하여 여러 속성을 바탕으로 분류하며, 대상(객체)을 정의한다. 그리고 이러한 인간 본연의 생각 방식을 프로그래밍에 도입한 것이 바로 객체지향 프로그래밍이다.

2) 객체지향 프로그래밍 (Object Orient Programming, OOP)

앞서 우리는 객체가 사람의 생각하는 방식이라는 점을 확인했다. 하지만, 컴퓨터는 사람과는 다른 방식으로 정보를 처리하며, 이는 사람으로 하여금 컴퓨터에 대한 고도의 이해를 요구하는 장벽이 되었다. 객체지향 프로그래밍이란 이러한 객체 개념을 프로그래밍 기법으로 도입한 것이다. 즉 코드를 보다 사람이 생각하는 방식에 가깝게 조직화 할 수 있도록한 것이다.
이러한 객체지향 프로그래밍은 흔히 class라는 문법으로 대표된다. 이하의 예는 C++의 class다.
// C++, 기초적인 class class example { private: int member_var1; // 1. 멤버 변수 double member_var2; public: example(){ member_var1 = 42; member_var2 = 42.4242; }; ~example(){...}; void member_func1(int iparam) { ... }; // 2. 멤버 함수 } // 실제 사용 방법 example test; test.member_func1(42);
C++
복사
1.
멤버 변수: 컴퓨터의 세계에서 객체란, 구분 가능한 대상이란, 곧 데이터다. 0과 1로 이루어진 일련의 비트 덩어리들은 메모리의 영역에 존재하며, 우리는 여기에 변수라는 이름을 붙여서 의미를 부여하고 조작해왔다. 이러한 변수들을 활용하면 객체를 구성하는 여러 속성 중 값의 형태로 정해지는 것을 표현할 수 있다.
2.
멤버 함수: 객체란 행위의 주체가 될 수 있다. 여러 변수들이 객체의 정적인 속성을 표현한다면, 함수는 이러한 객체의 행위를 표현할 수 있다.
즉, 객체의 도입을 통해서 프로그램에 “의미”를 기준으로 데이터와 코드를 분류할 수 있게 되었다.

2. C언어에서 객체지향을 구현해보자 - ft_vector

1) C언어에서의 객체

C언어는 객체지향 개념의 등장 이전에 개발된 언어이다. 따라서 언어 차원에서 객체지향(class)을 지원하지 않는다. 하지만, 객체지향은 방법론이고 언어의 기능과 상관없이 추구할 수 있다. 약간의 꼼수를 통해서 C에서 객체지향을 추구해보자.
먼저 class가 데이터와 코드로 구성된다는 점에 따르면, C에서는 struct와 함수포인터를 활용하여 유사하게 따라할 수 있다. 구조체를 이용해서 데이터들을 캡슐화 할 수 있고, 함수 포인터를 그 멤버 변수로 가지게 하여 멤버 함수를 구현할 수 있다. 이 방식에 따라서 위의 C++클래스를 C에서 구현하면 다음과 같다.
// C, 구조체와 함수 포인터로 구현한 유사 객체 struct example { int member_var1; double member_var2; void (*member_func1)(struct example *this, int iparam); } void func(struct example *this, int iparam) { ... } // 사용 방법 예제 struct example test; test.member_func1 = func; // 함수 포인터에 대한 별도 할당 필요함 test.member_var1 = 42; test.member_var2 = 42.4242; test.member_func1(&test, 42);
C
복사
여기서 눈여겨 볼 부분은 멤버함수 포인터의 this 포인터이다. C++에서 모든 객체의 멤버 함수는 묵시적으로 객체 자신을 가리키는 this포인터를 첫 인자로 갖는다. C의 경우에는 컴파일러가 해당 부분에 대한 지원을 해주지 않기 때문에 위와 같이 명시적으로 this 포인터를 받아올 필요가 있다. C의 객체에서는 오직 this포인터에 대한 참조를 통해서만 객체 내부에 접근할 수 있다.

2) ft_vector

해당 라이브러리는 C언어의 한계를 일부나마 극복하고자 하는 의도에서 시작되었다. 과제의 크기가 커지고, 구현이 복잡하게 얽히기 시작하자 여러 문제가 발생하였다. 본격적으로 자원의 관리가 힘들어졌는데, 각종 예외처리를 비롯한 다양한 상황에 따른 free()의 호출이 힘들어졌다. 또한 여러 프로젝트에 걸쳐서 중복되는 구조도 다수 발견되었는데, 이를 통합하는 데에는 문제가 있었다.
이러한 문제들을 극복하기 위해서 C언어로 일반화된 자료구조를 구현하여 재활용하고자 하는 목표를 삼았으며, 그 첫 목표로 std::vector를 모사한 ft_vector가 선정되었다.
/* // C++의 경우라면.... class t_ft_vector { private: void *pbuffer; size_t size; size_t capacity; size_t data_size; public: t_ft_vector(); ~t_ft_vector(); void *at(struct s_ft_vec *this, size_t idx); void *front(struct s_ft_vec *this); void *back(struct s_ft_vec *this); int empty(struct s_ft_vec *this); void clear(struct s_ft_vec *this); int push_back(struct s_ft_vec *this, void *elem); int resize(struct s_ft_vec *this, size_t size); } */ typedef struct s_ft_vec { // 위에서 private하게 의도된 멤버에 대해서는 사실 드러내선 안된다. void *pbuffer; // content가 저장될 큰 버퍼를 가리키는 포인터 size_t size; // pbuffer 내부에 초기화된 content의 개수 size_t capacity; // pbuffer 내부에 초기화 가능한 최대 content의 개수 size_t data_size; // 1개 content의 byte size // int (*construct_data)(void *address, void *param); // content의 생성자 void (*delete_data)(void *data); // content의 소멸자 void *(*at)(struct s_ft_vec *this, size_t idx); // idx로 content 접근 void *(*front)(struct s_ft_vec *this); // 가장 앞 content void *(*back)(struct s_ft_vec *this); // 가장 끝 content int (*empty)(struct s_ft_vec *this); // 벡터의 비어있는지 여부 void (*clear)(struct s_ft_vec *this); // 벡터의 내용물 전부 지움 int (*push_back)(struct s_ft_vec *this, void *elem); // 벡터 뒤에 content추가 int (*resize)(struct s_ft_vec *this, size_t size); // 벡터의 크기 조정 } t_ft_vector;
C
복사
1차적인 목표는 C++의 STL에 정의된 vector를 모사하는데에 있었다. vector컨테이너는 가변 길이 배열이며, 템플릿 문법을 사용하여 모든 자료형에 대응될 수 있는 범용 자료구조이다. 가능한 다양한 종류의 데이터 타입에 대응할 수 있는 일반성을 갖추기 위해서 void*를 적극적으로 사용했으며, 실제 벡터의 content에 specific한 작업을 해야하는 경우에 대해서는 모두 type-casting으로 처리하였다.
// 벡터 객체의 생성자와 소멸자 // default constructor t_ft_vector *construct_ftvec( int (*cd)(void *paddr, void *pparam), void (*dd)(void *paddr), size_t s); // copy constructor t_ft_vector *construct_ftvec_copy( t_ft_vector *src, int (*copy)(void *pdst_node, void *psrc_node)); // destructor void destruct_ftvec(t_ft_vector *this);
C
복사
언어 차원에서 객체지향을 지원하지 않기 때문에 객체지향이 갖는 여러 미덕 중 캡슐화(추상화, 구현의 숨김)에 집중하였다. 또한 일반적으로 모든 타입에 대응할 수 있도록 구현하여 다형성(코드의 재활용)을 획득했다. 끝으로 명시적으로 생성자와 소멸자를 호출할 수 있도록 구현하여 자원관리에서 편리함과 안정성을 동시에 추구했다.
또한 자원의 획득 및 해제에 더해서, 위 구조체는 멤버함수를 함수포인터의 형태로 들고있는데, 이를 일괄되게 초기화해주는 과정도 필수적이다.
무엇보다 C++의 런타임과는 달리 암시적 소멸자 호출 등이 존재하지 않기 때문에 벡터 컨테이너의 컨텐츠에 해당하는 데이터를 생성 및 해제해주는 함수를 함수포인터로 들고 있을 필요가 있다.
실제 사용 예시는 다음과 같다.
#include <stdio.h> #include <stdlib.h> #include "../FT_Vector/ft_vector.h" typedef struct test_struct // 벡터 컨테이너의 content가 될 객체 { int number; char *str; // 추후 동적으로 할당되기에 해제가 필요함 } t_test; struct param_test // 매개변수를 위한 구조체 { int n; char *str; }; int construct_test(void *paddr, void *pparam) // 컨텐츠의 생성자 { ((t_test *)paddr)->number = ((struct param_test *)pparam)->n; ((t_test *)paddr)->str = malloc(((struct param_test *)pparam)->n * sizeof(char)); if (((t_test *)paddr)->str == NULL) { return (0); } for (int i = 0; i < ((struct param_test *)pparam)->n; ++i) { ((t_test *)paddr)->str[i] = ((struct param_test *)pparam)->str[i]; } return (1); } void destruct_test(void *paddr) // 컨텐츠의 소멸자 { if (paddr != NULL) { free(((t_test *)paddr)->str); // test_struct의 내부의 동적 할당 메모리의 할당 해제 } } void check_leak(void) // 메모리 유출을 검사하기 위한 이벤트 핸들러 { system("leaks --list -- vec_test"); } void print_test_vec_all(t_ft_vector *vec) // 벡터의 내용물을 확인하기 위한 함수 { for (int i = 0; i < vec->size; ++i) { printf("%s\n", ((t_test *)(vec->at(vec, i)))->str); } } int main() { // basic check. struct param_test param1 = {12, "hello world"}; struct param_test param2 = {6, "start"}; struct param_test param3 = {4, "end"}; // constructor test printf("call constructor\n"); t_ft_vector *test_vec = construct_ftvec(construct_test, destruct_test, sizeof(t_test)); printf("check is empty : %d \n", test_vec->empty(test_vec)); // push_back test printf("call push_back\n"); test_vec->push_back(test_vec, &param2); for (int i = 0; i < 12; ++i) test_vec->push_back(test_vec, &param1); test_vec->push_back(test_vec, &param3); // content check printf("call at, check content.\n"); print_test_vec_all(test_vec); // front, back check printf("call ft_vector front : %s\n" , ((t_test *)(test_vec->front(test_vec)))->str); printf("call ft_vector back : %s\n" , ((t_test *)(test_vec->back(test_vec)))->str); // resize printf("call resize, change to 5, check content.\n"); test_vec->resize(test_vec, 5); print_test_vec_all(test_vec); printf("check is empty : %d \n", test_vec->empty(test_vec)); // clear printf("call clear, check content.\n"); test_vec->clear(test_vec); print_test_vec_all(test_vec); // do nothing printf("check is empty : %d \n", test_vec->empty(test_vec)); // destruct vector printf("calling destructor.\n"); destruct_ftvec(test_vec); printf("========================\n"); atexit(check_leak); return (0); }
C
복사
구조적 장점으로는 자원관리의 용이성을 들 수 있다. 캡슐화에 의해서 객체와 연관된 자원이 한 데에 모여있으므로, 객체의 필요에 따라서 관련된 자원을 일괄 해제 및 생성할 수 있다. 여전히 소멸자를 호출해줘야 한다는 점은 존재하지만, leak이나 댕글링 포인터 문제를 크게 완화할 수 있다. 이는 예외처리 상황에서 더욱 빛을 발한다.
특히 void*와 data_size에 의한 ft_vector의 다형성은 코드의 재활용성을 크게 높여준다. 기존의 경우 자료구조를 활용하기 위해서는 각 데이터 타입에 대응하는 자료구조를 매번 구현해줘야 했다. 이는 곧 약간의 차이로 불필요한 반복이 대거 발생한다는 것을 의미한다. 그러나 ft_vector는 여러 함수포인터와 void*의 도움으로 거의 모든 타입에 대해서 대응할 수 있다.
하지만, 구조적 한계도 명확했다. 가장 큰 문제는 언어상에서 지원하지 않는 기능을 억지로 구현하려 하다 보니 자연스러운 객체의 생성 및 소멸이 불가능하다는 점이다. 또한 일반화를 위해서 void *를 다수 도입하였는데, 이 부분으로 인해서 다수의 캐스팅이 필요해졌다. 이는 곧 가독성을 저하시키는 결과를 가져왔다.
또한 본질적으로 구조체로 구현이 되어있어 접근 통제가 사실상 불가능하다. 구현을 노출하지 않는다고 해도, 접근 통제가 실패한다면, 해당 코드가 의도한 동작을 보장하지 못할 수 있다.
사용 과정에서 발생하는 다수의 포인터 연산도 문제가 될 수 있다.즉 코드를 구조화 한다는 것은 결국 접근 과정에서 포인터를 활용한다는 뜻임을 알 수 있다. 단순한 변수에 접근하는 at()만 봐도 2회 이상의 포인터 연산이 추가적으로 발생한다. 일반적인 상황에서는 추가적인 포인터 연산으로 인한 오버헤드가 크지는 않겠지만, 임베디드 환경 등 상황에 따라에서는 문제가 될 수 있다.

마치면서…

사소한 동기에서 출발한 프로젝트였지만, 생각보다 많은 시간을 소모하게 되어서 좀 아쉽다. 다만, 개인적인 흥미는 충분히 채웠으니 후회하지는 않는다. 관심 있는 여러분은 시도해보는 것도 좋다고 생각된다. 특히 앞으로 있을 여러 42과제에서 큰 도움이 될 것을 기대한다.