본문 바로가기
모던 C 언어/C언어 객체지향

1-1. 객체지향 C언어? (클래스, 다형성)

by 커널패닉 2020. 8. 25.
반응형

객체지향이란?

C언어의 객체지향이라는 주제로 이야기를 하려면, 우선 "객체지향"이란 무엇인지부터 짚고 넘어가야 한다. 나는 개인적으로 "클린 아키텍쳐"에서 말하는 객체지향 개념을 선호한다. 마틴 파울러는 클린 아키텍쳐에서 객체지향을 "객체지향이란 다형성을 이용하여 전체 시스템의 모든 소스코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력"이라고 정의한다. 이 말을 장황하게 풀어쓰면 다음과 같다. 객체지향을 활용하면 #1. SW 세부사항들이 핵심사항을 의존하도록 구성하고, #2. 각 SW 모듈간 의존성은 최소화하며, #3. 인터페이스 등을 통해 SW 다형성을 확보하여 #결론. 시스템의 개발/수정/유지보수의 용이성을 크게 높을 수 있다.
이 정의대로면 C언어로도 충분히 객체지향적인 코드를 짤 수 있다. 아니, 오늘날 활발히 사용되는 많은 C 코드는 객체지향적으로 짜여져 있다. 하지만 이번 장에서는 객체지향을 class라는 협소한 의미로만 사용하려고 한다. class 개념은 객체의 속성(상태)과 동작(메서드)를 하나의 구조에 저장, 상속, 다형성, 캡슐화 등 많은 편리한 기능을 제공해 준다. 이는 C언어에서 언어적으로 제공하지 않는 기능들이다.

 

C언어에서 객체지향이 가능한가?

C언어는 절차지향 언어라고 불리고 있다. "언어"의 설계 측면에서 본다면 맞는 말이다. 분명히 C에는 객체지향 언어에서 흔히 제공하는 class나 interface와 같은 키워드가 없다. 하지만 초창기 Cpp 코드는 C로 변환된 후에 컴파일이 되었다. 또 오늘날에도 Vala라는 객체지향 언어는 C로 변환된 다음 컴파일된다. 즉 객체지향 코드가 C로 변환이 된다는 의미이다. 그렇다면 객체지향 코드는 어떤 식으로 C언어로 구현이 되는 것일까? 여기서는 객체지향이 가지는 여러가지 특성 ‘클래스(속성과 동작을 하나의 구조에 저장)’, '상속', '다형성', ‘캡슐화’ 관점에서 C언어로 객체지향을 구현하는 방법에 대해 살펴보려고 한다.

* 아래의 C언어 객체지향 구현 예시는 Gobject의 구현 방식을 참조하였다. 소개한 방법 외에도 다양한 방식으로 C언어에서 객체지향을 구현할 수 있다.

 

클래스

비슷하게 생긴 Cpp과 C를 비교해보자. Cpp는 class를 이용해서 class에서 사용할 상태와 메서드들을 선언할 수 있다. 반면 C의 struct에서는 오직 상태만을 저장할 수 있다. 일반적으로 C에서 struct에 관한 함수들을 작성한다면, 이름으로 특정 구조체와 관련된 함수임을 나타낸다. 예를 들어 x, y, width, height를 변수로 가지는 Rect라는 구조체가 있다면, 아래와 같이 파일을 작성하는 것이 일반적이다.

/* rect.h */

struct Rect {
	unsigned int x;
	unsigned int y;
	unsigned int width;
	unsigned int height;
};

void rect_move(struct Rect *rect, unsigned int x, unsigned int y);
void rect_draw(struct Rect *rect);
/* rect.c */

#include <stdio.h>
#include "rect.h"

void rect_move(struct Rect * rect, unsigned int x, unsigned int y) {
	/* 사각형 이동 */
	rect->x = x;
	rect->y = y;
	printf("사각형을 움직인다.\n");
}


void rect_draw(struct Rect *rect) {
	/* 사각형 그리는 작업 수행 */
	printf("사각형을 그린다.\n");
	printf("x=%u y=%u w=%u h=%u\n", 
			rect->x, rect->y, rect->width, rect->height);
}

int main() {
	struct Rect rect = { 0, 0, 30, 40 };

	rect_move(&rect, 1, 2);
	rect_draw(&rect);
}

출력

$ gcc rect.c
$ ./a.out   
사각형을 움직인다.
사각형을 그린다.
x=1 y=2 w=30 h=40

struct Rect가 상태와 메서드를 함께 소유하고 있지는 않지만, Rect 구조체를 다루는 함수들이 같은 파일에 정의되어 있다. 이 함수들은 첫 번 째 인자로 Rect 구조체를 받아서, Cpp의 this 키워드와 동일하게 사용하고 있다. 따라서 동일 파일 안에 구조체를 다루는 함수를 정의하는 방법은, class에서 메서드를 사용하는 것과 기능적으로 동일하게 작동한다. 여기서 더 나아가서 함수포인터를 사용하면 생김새도 Cpp의 메서드와 유사하게 만들 수 있다. 아래는 함수포인터를 사용하여 rect.c 파일을 다시 작성해 보았다.

/* rect.h */

struct Rect {
	void (*move)(struct Rect *rect, unsigned int x, unsigned int y);
	void (*draw)(struct Rect *rect);

	unsigned int x;
	unsigned int y;
	unsigned int width;
	unsigned int height;
};

struct Rect* rect_init(void);
void rect_move(struct Rect * rect, unsigned int x, unsigned int y);
void rect_draw(struct Rect *rect);
/* rect.c */

#include <stdio.h>
#include <stdlib.h>
#include "rect.h"

/* XXX 편의상 메모리 검사하는 코드는 생략 */

struct Rect* rect_init(void) {
	struct Rect *rect = malloc(sizeof(struct Rect));

	rect->move = rect_move;
	rect->draw = rect_draw;

	rect->x = 0;
	rect->y = 0;
	rect->width = 30;
	rect->height = 40;

	return rect;
}

void rect_move(struct Rect *rect, unsigned int x, unsigned int y) {
	/* 사각형 이동 */
	rect->x = x;
	rect->y = y;
	printf("사각형을 움직인다.\n");
}

void rect_draw(struct Rect *rect) {
	/* 사각형 그리는 작업 수행 */
	printf("사각형을 그린다.\n");
	printf("x=%u y=%u w=%u h=%u\n", 
			rect->x, rect->y, rect->width, rect->height);
}

int main() {
	struct Rect *rect = rect_init();

	rect->move(rect, 1, 2);
	rect->draw(rect);

	free(rect);
}

출력

$ gcc rect.c
$ ./a.out   
사각형을 움직인다.
사각형을 그린다.
x=1 y=2 w=30 h=40

main 함수의 생김새가 Cpp class와 매우 유사해졌다. 구조체의 초기화 코드(new_rect)에서 struct Rect의 함수포인터 draw, move에 실제 함수를 할당했기 때문에, 이후에 함수포인터를 사용하는 것 만으로도 실제 함수를 호출하는 것과 동일한 기능을 할 수 있다.

 

다형성

위키백과를 보면 다형성은 다음과 같이 정의되어 있다. "프로그램 언어의 다형성(多形性, polymorphism; 폴리모피즘)은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들(상수, 변수, 식, 오브젝트, 함수, 메소드 등)이 다양한 자료형(type)에 속하는 것이 허가되는 성질을 가리킨다.". 주로 객체지향 언어의 오버로딩, 오버라이딩을 사용한다. C언어에서는 typecasting을 통해 오버라이딩을 사용하는 다형성을 구현할 수 있다.

/* shape.h */

struct Shape {
	void (*move)(struct Shape *rect, unsigned int x, unsigned int y);
	void (*draw)(struct Shape *rect);
};

struct Rect {
	void (*move)(struct Rect *rect, unsigned int x, unsigned int y);
	void (*draw)(struct Rect *rect);

	unsigned int x;
	unsigned int y;
	unsigned int width;
	unsigned int height;
};

struct Rect* rect_init(void);
void rect_move(struct Rect * rect, unsigned int x, unsigned int y);
void rect_draw(struct Rect *rect);

struct Circle {
	void (*move)(struct Circle *circle, unsigned int x, unsigned int y);
	void (*draw)(struct Circle *circle);

	unsigned int x;
	unsigned int y;
	unsigned int radius;
};

struct Circle* circle_init(void);
void circle_move(struct Circle *circle, unsigned int x, unsigned int y);
void circle_draw(struct Circle *circle);
/* shape.c */

#include <stdio.h>
#include <stdlib.h>
#include "shape.h"

/* XXX 편의상 메모리 검사하는 코드는 생략 */

struct Rect* rect_init(void) {
	struct Rect *rect = malloc(sizeof(struct Rect));
	/* TODO 메모리가 올바르게 할당되었는지 검사 */

	rect->move = rect_move;
	rect->draw = rect_draw;

	rect->x = 0;
	rect->y = 0;
	rect->width = 30;
	rect->height = 40;

	return rect;
}

void rect_move(struct Rect *rect, unsigned int x, unsigned int y) {
	/* 사각형 이동 */
	rect->x = x;
	rect->y = y;
	printf("사각형을 움직인다.\n");
}


void rect_draw(struct Rect *rect) {
	/* 사각형 그리는 작업 수행 */
	printf("사각형을 그린다.\n");
	printf("x=%u y=%u w=%u h=%u\n", 
			rect->x, rect->y, rect->width, rect->height);
}

struct Circle* circle_init(void) {
	struct Circle *circle = malloc(sizeof(struct Circle));

	circle->move = circle_move;
	circle->draw = circle_draw;

	circle->x = 3;
	circle->y = 4;
	circle->radius = 7;

	return circle;
}

void circle_move(struct Circle *circle, unsigned int x, unsigned int y) {
	/* 원 이동 */
	circle->x = x;
	circle->y = y;
	printf("원을 움직인다.\n");
}

void circle_draw(struct Circle *circle) {
	/* 원을 그리는 작업 수행 */
	printf("원을 그린다.\n");
	printf("x=%u y=%u radius=%u\n", 
			circle->x, circle->y, circle->radius);
}

int main() {
	struct Rect *rect = rect_init();
	struct Circle *circle = circle_init();

	struct Shape* shapes[2] = {
		(struct Shape*)rect, 
		(struct Shape*)circle,
	};

	for (int i = 0; i < 2; i++) {
		shapes[i]->move(shapes[i], i * 100, i * 200);
		shapes[i]->draw(shapes[i]);
	}

	for (int i = 0; i < 2; i++) {
		free(shapes[i]);
	}
}

출력

사각형을 움직인다.
사각형을 그린다.
x=0 y=0 w=30 h=40
원을 움직인다.
원을 그린다.
x=100 y=200 radius=7

Rect와 Circle이라는 서로 다른 객체가 Shape로 형변환이 되어서 사용되고 있다. C에서는 어떤 방식으로 형변환을 하여도 타입체크를 하지 않기 떄문에, 이처럼 완전히 다른 구조체로 형변환을 하여 다형성을 확보할 수 있다. 이 때 주의할 점은 Shape와 Rect, Circle 모두 함수포인터의 위치가 동일해야 한다는 것이다. 만약 Rect와 Shape간 함수포인터 순서가 바뀐다면 다음과 같이 Segfault가 발생하게 된다.

/* shape.h */

struct Shape {
	void (*move)(struct Shape *rect, unsigned int x, unsigned int y);
	void (*draw)(struct Shape *rect);
};

struct Rect {
	unsigned int x;
	unsigned int y;
	unsigned int width;
	unsigned int height;

	/* 위치가 변경되었다. */
	void (*move)(struct Rect *rect, unsigned int x, unsigned int y);
	void (*draw)(struct Rect *rect);
};

struct Rect* rect_init(void);
void rect_move(struct Rect * rect, unsigned int x, unsigned int y);
void rect_draw(struct Rect *rect);

struct Circle {
	void (*move)(struct Circle *circle, unsigned int x, unsigned int y);
	void (*draw)(struct Circle *circle);

	unsigned int x;
	unsigned int y;
	unsigned int radius;
};

struct Circle* circle_init(void);
void circle_move(struct Circle *circle, unsigned int x, unsigned int y);
void circle_draw(struct Circle *circle);

(shape.c 파일은 기존과 동일하다.)

 

출력

[1]    12345 segmentation fault (core dumped)

다형성의 구성 요소 중 하나인 오버라이딩은 확인하였는데, 오버로딩은 어떨까? 일반적으로 C에서는 중복된 이름의 함수 선언을 허용하지 않기 때문에 오버로딩은 구현하기 쉽지 않다. 굳이 오버로딩을 사용해야 한다면, 가변인자(VA_ARGS)를 이용하면 오버로딩을 구현할 수 있다. 하지만 이 방법은 Gobject에서 사용하는 방법은 아니기 때문에, 이런 방법이 있다 정도만 알고 넘어가려 한다.

 

반응형