42Seoul

FT_PRINTF - Mandatory

millar 2023. 5. 15. 15:35

보너스에서 생각하지 못한 부분이 하나 있었나 봅니다. 125점으로 돌아오겠습니다.

FT_PRINTF

 

Because putnbr and putstr aren’t enough
putnbr와 putstr으로는 만족할 수 없기 때문에

Summary: This project is pretty straight forward. You will recode printf. Hopefully you will be able to reuse it in future projects without the fear of being flagged as a cheater.
요약 : 이 프로젝트는 꽤 단순합니다. 여러분은 printf 함수를 직접 구현하시면 됩니다. 희망컨대 여러분들은 cheater로 지목될 수 있다는 두려움 없이 추후 프로젝트에서 이것을 재활용할 수 있습니다.

You will mainly learn how to use variadic arguments.
여러분은 주로 가변 인자 (variadic arguments) 를 사용하는 방법에 대해 배울 것입니다.

개요

 리뷰가 많이 늦었습니다. 이번 과제는 stdio 헤더에 존재하는 함수 'printf'를 구현하는 것입니다. 어떤 언어든 출력하는 함수를 가장 먼저 접했던 경험이 있으실 겁니다.

 

 printf의 핵심은 가변 인자의 개념과 코드 간략화입니다. 가변 인자가 어떻게 동작하는지 확실하게 학습하고 시작해야 합니다. 사용할 수 있는 소스 코드 파일은 제한이 없으므로, 어떻게든 구현할 수 있습니다. 하지만 복잡하게 만들면 동료 평가에서 설명하기 어려울 수 있으므로 소스 코드 파일을 남용하지 않는 편이 좋습니다.

 

 함수를 사용할 때 인자를 어떻게 입력하는지에 따라 출력문을 원하는 대로 만들 수 있었는데, 이제는 이것을 스스로 구현해야 합니다. 해당 과제를 수행하면 출력 함수의 내부 구조, 동작 원리 등을 학습할 수 있습니다.

 

목표

The versatility of the printf function in C represents a great exercise in programming for us. This project is of moderate difficulty. It will enable you to discover variadic functions in C.
C에서 printf 함수의 다재다능함은 프로그래밍에 있어 우리에게 훌륭한 연습이 됩니다. 이 프로젝트는 중간 정도의 난이도를 가지며, 여러분들이 C에서 가변 함수들을 배울 수 있도록 도와줍니다.

The key to a successful ft_printf is a well-structured and good extensible code.
성공적인 ft_printf의 핵심은 체계적이고 확장성 있는 코드입니다.

과제에서 요구하는 서식지정자에 대해 원본 함수(printf)와 똑같이 출력하는 ft_printf 함수를 만들어야 합니다.

 

 

과제의 요구 조건을 살펴 봅시다.

1. 실제 printf의 동작을 모방한 ft_printf를 포함하는 라이브러리를 작성해야 합니다.

2. 다음 서식 지정자를 구현해야합니다 : cspdiuxX%

3. 사용 가능한 외부 함수: malloc, free, write, va_start, va_arg, va_copy, va_end

 

과제의 금지된 조건을 살펴봅시다.

1. 실제 printf처럼 버퍼 관리를 수행해서는 안 됩니다.

    + 이에 대해 동료 평가에서 더 자세히 이야기 할 것이 있습니다.

2. ar 명령어를 이용하여 라이브러리를 만들어야 합니다. libtool을 사용하는 것은 금지됩니다.
# libtool은 일종의 라이브러리 도구입니다. GNU 빌드 시스템에서 나온 GNU 프로그래밍 도구이며 컴파일된 포터블 라이브러리를 만드는 데 이용한다고 하는데, 과제에서 항상 해왔던 것처럼 ar명령어로 라이브러리를 만들도록 제시하고 있습니다.


구현 전 사전 지식

1. 가변 인자의 개념

2. printf 함수의 개념


1. 가변 인자

typedef: va_list - stdarg.h

관련 함수: va_start, va_end, va_copy, va_arg

 

 우리는 함수를 만들 때, 매개 변수의 타입과 개수를 정하고 작성했습니다. 그러면 printf 함수에 의문점이 하나 생깁니다. "이 함수는 내가 인자를 몇 개 쓸 줄을 어떻게 알고 있는 거지? 그리고 몇 번째 인자가 어떤 형인지 어떻게 알고 있는 거야?". 가변 인자는 이를 가능하게 합니다.

 

가변 인자를 사용한 예시 코드를 통해 설명해 보겠습니다.

#include <stdio.h>
#include <stdarg.h>

void func(int num, ...)
{
    va_list vlist;
    va_list vlist_cp;
 
    va_start(vlist, num);

    for (int i = 0; i < num; i++)
    {
        va_copy(vlist_cp, vlist);
        printf("%d번째 가변 인자: %d\n",i + 1, va_arg(vlist, int));
        for (int j = 0; j < num; j++)
            printf("%d번째 복제 가변 인자: %d\n",j + 1, va_arg(vlist_cp, int));
    }

    va_end(vlist);
}

int main()
{
    func(3, 1, 2, 3);
    return (0);
}

해당 코드는 가변 인자 관련 함수들을 모두 사용하여 어떤 기능을 나타내는지 보여주기 위해, 적절하게 표현할 수 있도록 임의로 작성한 모습입니다.

  • void func(int num, ...): 가변 인자를 사용하는 함수는 반드시 하나 이상 인자 형태가 결정되어있어야 합니다. 이후 나머지 인자들은 개수와 형태가 정해져 있지 않기 때문에, '...'으로 표현합니다.
  • va_start(가변 인자 리스트 포인터, 마지막 named 인자): func에서도 설명했듯, 가변 인자를 사용하는 함수는 반드시 하나 이상 인자의 형태가 결정되어 있어야 합니다. 필요에 따라서 void func(int n1, int n2, ...) 꼴로 함수를 만들 수 있습니다만, va_start의 두 번째 인자는 반드시 마지막 명시된 인자여야 합니다. va_start(vlist, n2)로 되어있어야지 n1이 들어가면 컴파일 에러가 발생합니다.             + 가변 인자들은 연속된 메모리 공간에 할당되어 있기 때문에 함수의 첫 번째 인자 위치를 알아야 합니다. 첫 번째의 인자로 num이         선두를 달리며, 따라오는 인자들은 num의 다음 위치를 차례대로 갖습니다.
  • va_copy(복사할 가변 인자 리스트 포인터, 원본 리스트 포인터): 위의 코드에서 vlist_cp가 현재 상태의 vlist를 복사합니다. 따라서 가변 인자의 어느 위치에서 복사가 되는지를 볼 수 있는 출력문을 보면, 원본이 출력된 지점부터 출력을 시작하여 3개씩 출력되는 모습을 볼 수 있습니다.
  • va_arg(가변 인자 리스트 포인터, 가져올 형태(타입 캐스팅)): func(3, 1, 2, 3)에서 1, 2, 3이 가변인자 이므로 vlist에서 1을 가장 먼저 가져오게 됩니다. 가져올때 형변환이 이루어지므로 의도에 맞게 인자를 구성해야 합니다. 1을 가져왔다면 그다음은 포인터가 2를 가리키게 됩니다.
  • va_end(가변 인자 리스트 포인터): 가변 인자를 모두 처리하고 나서, 마지막에 한 번 호출하여 사용이 끝난 포인터를 NULL 포인터로 초기화하는 역할을 맡습니다.

 

2. printf()

함수 원형: int printf(const char *format, ...) - stdio.h

반환 값(출력문을 화면에 표시한 후): 출력한 문자열의 길이, 실패 시 -1

 

 printf 함수는 format을 확인하면서 서식지정자를 만나면 가변 인자를 불러와 형식에 맞게 출력하고, 그렇지 않으면 단순 문자로 취급하여 그냥 출력합니다. 출력을 할 수 없는 경우에 -1을 반환하는데, 이것은 정의되지 않은 행위(Undefined behavior)와 다릅니다. 이것을 잘 구분하셔야 합니다.


구현

Pseudo code

int	ft_printf(const char *format, ...)
{
    가변 인자 포인터 선언		//vlist
    반환 길이 선언		//plen

    가변 인자를 시작한다.
    format문자열로 반복문 시작한다.
    {
    	if '%'문자 인지 확인한다.
            서식문자에 따른 함수를 실행한다.
	else
            문자를 출력한다.
    }
    가변 인자를 종료한다.
    출력한 길이를 반환한다.
}

int	check_sp(va_list vlist, const char c) //서식지정자를 확인하는 함수
{
    함수 포인터 선언 cspdiux%에 관한 8개의 함수를 저장	type_arr
    서식 문자가 몇번째 문자인지 확인할 변수 선언		idx

    서식문자의 인덱스를 확인.
    해당 인덱스에 맞는 서식문자 출력 함수를 반환한다.
}

int	type_num(char *type, char sp) //서식문자의 인덱스를 표시하는 함수
{
    서식 지정자의 인덱스를 반환한다.
}

 

서식지정자: cs%

int	type_c(va_list vlist)
{
    문자 하나를 표시할 변수 선언	// c

    가변 인자를 char로 받아온다.
    문자 하나를 출력한다.
    출력한 길이를 반환한다.
}

int	type_s(va_list vlist)
{
    문자열을 표시할 변수 선언	// str

    가변 인자를 char * 받아온다.
    if 가변 인자 문자열이 널이라면
        null을 출력한다.
    문자열을 출력한다.
    출력한 길이를 반환한다.
}

int	type_percent(void)
{
    '%'기호를 출력한다.
    출력한 길이를 반환한다.
}

c: 문자 하나를 출력합니다.

s: 문자열을 출력합니다. 널이라면 (null)로 표시됩니다.

%: '%'기호를 출력합니다.

 

서식지정자: di

int	type_i(va_list vlist)
{
    type_d를 실행한다.
}

int	type_d(va_list vlist)
{
    출력 길이를 표시할 변수 선언	//plen
    정수를 나타낼 변수 선언	//num

    가변 인자를 int로 받아온다.
    if 음수라면
        '-'를 출력한다.
    if 양수라면
        양수를 표시하는 함수를 호출한다.
    else
        음수를 표시하는 함수를 호출한다.
    출력 길이를 반환한다.
}

void	print_decimal_num_positive(int num)
{
    if 10 이상이면
    {
    	몫을 기준으로 재귀함수를 실행한다.
        나머지를 기준으로 재귀함수를 실행한다.
    }
    else
        수를 출력한다.
}

void	print_decimal_num_negative(int num)
{
    if -10 이하라면
    {
        몫을 기준으로 재귀함수를 실행한다.
        나머지를 기준으로 재귀함수를 실행한다.
    }
    else
        수를 출력한다.
}

d, i: 수를 10진법으로 출력합니다.

 

서식지정자: p

int	type_p(va_list vlist)
{
    포인터 선언	//ptr
    출력 길이를 표시할 변수 선언		/plen

    가변 인자를 void *로 받아온다
    if 주소값이 0이면(널 포인터)
        0x0꼴로 출력한다.
    0x를 먼저 출력한다(형식을 맞추기 위함)
    주소값을 출력하는 함수를 실행한다.
    출력 길이를 반환한다.
}

void	print_pointer(unsigned long num)
{
    if 16 이상이다.
    {
        몫을 기준으로 재귀함수를 실행한다.
        나머지를 기준으로 재귀함수를 실행한다.
    }
    else
        출력한다
}

p: 해당 변수의 주소값을 출력합니다. 16진법으로 주소값을 표시하기 때문에, 양수에 한해서 16으로 나눈 값을 재귀를 통해 표시했습니다. 널 포인터일 경우 주소값이 0이기 때문에 '0'으로 출력되는것이 아니라, 0x0으로 출력됩니다.

 

서식지정자: u

int	type_u(va_list vlist)
{
    부호없는 정수를 표시할 변수 선언	// num
    출력 길이를 표시할 변수 선언	// plen

    가변 인자를 unsigned int로 받아온다.
    부호 없는 정수를 출력한다.
    길이를 반환한다.
}

void	print_unsigned_num(unsigned int num)
{
    if 정수가 10 이상이다.
    {
        몫을 기준으로 재귀 함수를 실행한다.
        나머지를 기준으로 재귀함수를 실행한다.
    }
    else
        정수를 출력한다.
}

u: 부호 없는 10진수 정수를 출력합니다. 가변인자가 음수일 경우에는 오버플로우 사이클 동작 방식에 따라 양수로 바뀝니다.

 

서식지정자: x, X

int	type_lx(va_list vlist)
{
    16진수를 표시할 변수를 선언	// hexnum
    출력 길이를 표시할 변수를 선언	// plen

    가변 인자를 unsigned int로 받아온다.
    16진수로 수를 출력한다.
    출력 길이를 반환한다.
}

int	type_ux(va_list vlist)
{
    16진수를 표시할 변수를 선언	// hexnum
    출력 길이를 표시할 변수를 선언	// plen

    가변 인자를 unsigned int로 받아온다.
    16진수로 수를 출력한다.
    출력 길이를 반환한다.
}

void	print_lowercase_hex_num(unsigned int num)
{
    if 정수가 16 이상이다.
    {
        몫을 기준으로 재귀 함수를 실행한다.
        나머지를 기준으로 재귀함수를 실행한다.
    }
    else
        정수를 출력한다.
}

void	print_uppercase_hex_num(unsigned int num)
{
    if 정수가 16 이상이다.
    {
        몫을 기준으로 재귀 함수를 실행한다.
        나머지를 기준으로 재귀함수를 실행한다.
     }
     else
        정수를 출력한다.
}

x: 부호 없는 10진수를 16진수로 출력합니다. 소문자가 사용됩니다.

X: 부호 없는 10진수를 16진수로 출력합니다. 대문자가 사용됩니다.


동료 평가

1. 가변 인자의 개념을 설명할 수 있어야 합니다.

2. 원본 printf의 정의되지 않은 행동과 에러를 처리해야 합니다.

 

Defense

 동료 평가 중, printf 과제에서 논의 하는 3가지 지점은 정의 되지 않은 행위 처리, 출력 최대 길이 처리, write 함수의 -1 반환 상황처리입니다. 3가지 상황 모두 과제에서 정확히 방향을 정해주지 않았기 때문에 디펜스의 영역에서 해결해야 할 부분이라고 생각합니다.

 

 먼저 정의되지 않은 행위 처리입니다. 정말 많은 경우가 있습니다. 존재하지 않은 서식지정자를 사용하는 경우, 서식지정자의 개수와 가변 인자의 개수가 맞지 않는 경우, 서식지정자와 가변 인자의 매칭이 이상하게 되어있는 경우(%d인데 가변 인자가 문자열인 경우) 등 원본 printf에서는 예측할 수 없는 출력문을 발생시키고 그 출력문 길이만큼을 반환합니다.

 

 두 번째는 출력 최대 길이 처리입니다. print 함수의 반환 값이 int인데, 그러면 INT_MAX까지 출력할 수 있다고 생각할 수 있습니다. 출력을 시도하면 정말 출력이 됩니다. 물론 오래 걸립니다. 그럼, int의 최댓값을 넘어가면? 출력을 진행하지 않고 -1을 반환합니다. 하지만 write 함수로 출력 글자를 하나씩 찍어내는 우리의 함수는 이를 해냅니다.

 

 세 번째는 write 함수의 -1 반환 상황입니다. print가 어떻게 출력문을 구성하는지에 상관없이 우리는 write 함수를 사용해서 출력문 문자를 하나씩 일일이 찍어내야 합니다. 만약 3번의 write 함수를 사용하는 출력문이 있는데, 어떤 이유로 write 가 2번째 글자를 출력해야 하는 순간에서만 -1을 반환했다면? 출력 성공을 기호로 표시하면 "OXO"를 나타낼 것입니다. 그런데 이를 어떻게 처리해야 하는가에 대한 관련 사항입니다. write 함수가 -1을 반환하는 상황은 너무나도 많으니, 디펜스를 위해 잘 인지하고 있어야 합니다.

 

 이런 부분에 대해서 원본 함수와 정확히 일치하지 않으니 어떻게든 터트리는 분들이 계시는데 정말 그러지 마시길 바랍니다. 원본을 모방하는 함수를 구현하는 과제이기는 하지만, 제 생각에는 그럴 수 없다고 생각합니다.

 

그럼 어떻게 디펜스할 수 있을까요?

 

 저는 정의되지 않는 행위는 어떻게든 출력이 돼도 상관없다고 생각하여 따로 처리하지 않았고 출력 최대 길이도 처리하지 않았습니다. 다만 write 함수가 고장 났을 때는 지금까지 출력한 것은 어쩔 수 없으니, 출력을 멈추고 ft_printf가 -1을 반환하도록 했습니다.

 

 사실 3가지 모두 컨트롤할 수 있는 부분입니다. 소스 코드 개수 제한이 없기 때문입니다. 그런데 제 경험상 write 함수의 고장을 처리해 주는 부분 하나만으로 코드가 굉장히 더러워지고 설명하기 복잡해집니다. 정의되지 않은 행위는 원본과 비교하여 모든 상황을 어떻게든 맞추며 출력 길이의 최대값 제한은 출력하기 전, 출력될 길이를 먼저 계산하고 진행하면 됩니다. write 함수의 -1 반환 상황은 write 함수의 실행마다 반환 값을 확인하며 처리해 주면 됩니다. 그 대신 에러처리가 코드의 대부분을 차지하겠죠.

 

 이럴 경우, 원본 함수를 구현하는 과제가 아니라 printf의 에러 상황을 처리하는 과제로 바뀌어 버립니다. 과제에서 언급한 부분만이 원본을 강조할 수 있고 그 이외의 모든 사항은 디펜스의 영역이며, 피평가자가 과제에 대해서 이를 인지하고 있는지, 어떻게 처리하였는지를 의견 공유를 하면 됩니다. 동료 평가이니까요.

 

 "평가는 평가자 마음 아닌가요?" ... 그래도 원본을 똑같이 구현할 수가 없습니다. 아까 짚어두었던 "원본 printf처럼 버퍼 수행을 해서는 안 됩니다." 때문입니다. 우리가 버퍼를 만들고 그 안에 출력문을 담아 둔 다음에 출력할지 말지 결정할 수 있게만 해준다면 정말 편리하게 에러처리를 할 수 있습니다. 저 조건 때문에 우리는 원본보다 더 어려운 조건에서 원본을 모방하는 과제가 돼버리는 겁니다. 학습자의 입장에서 이렇게 학습하는 게 과연 맞을까요?


마치며

 과제도 어렵지만 디펜스도 어려운 부분이 많은 과제입니다. 과제에서 원본과 비교한다는 내용때문에 원본 그대로 만들어야 한다는 것은 납득하기 어렵습니다. 학습자의 입장에서는 에러 상황을 인식하고 처리하는 것은 하나가 아니라 두 가지로 봐야 한다고 생각합니다.