Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby C 확장 인수 파싱 완전 정복하기: 단계별 가이드

Ruby는 사람을 먼저, 기계를 나중에 생각하며 만든 훌륭한 언어입니다. 읽고 쓰기가 쉬울 뿐 아니라, 같은 동작을 구현하는 방법이 다양하고, 스스로 지었을 법한 메서드 이름을 입력해 보면 표준 라이브러리에 실제로 존재하곤 합니다.

덕분에 Ruby의 인수 체계는 매우 유연해서 API를 아주 명확하게 표현할 수 있습니다. 하지만 여기에는 단점이 따릅니다. 바로 C 확장 개발자 입장에서 Ruby는 파싱하기 상당히 까다롭다는 것입니다!

이 글에서는 C로 작성된 복잡한 Ruby API를 구성하는 두 가지 방법을 살펴보겠습니다.

  • rb_define_method로 정의한 뒤 rb_scan_args로 파싱하기
  • Ruby 인터페이스를 활용하기

그럼 시작해 보겠습니다!

C와 Ruby: 기본 개념

앞서 언급했듯이 C 확장 개발자에게 Ruby는 파싱이 만만치 않은 언어입니다.

반면 Ruby가 작성된 언어인 C의 아름다움은 단순함에서 나옵니다. 함수 매개변수 역시 마찬가지죠.

  • <데이터 타입> <변수 식별자>
  • 가변 인수를 위한 ...

이런 단순함 덕분에 이해하기 어렵지 않은 코드베이스를 유지할 수 있습니다. C에서 함수를 정의하는 가장 복잡한 형태조차 몇 줄 안 됩니다.

하지만 Ruby 코드베이스를 위한 C 확장을 직접 작성해 보면, 복잡성이 어디서부터 시작되는지 몸소 느끼게 됩니다. 걱정하지 마세요. Ruby MRI 개발자들이 우리를 위한 도구를 이미 마련해 두었습니다.

Ruby C 확장에서 메서드 정의하기

먼저 반드시 한 번쯤 사용하게 되는 rb_define_method부터 살펴보겠습니다. 함수 시그니처는 다음과 같습니다.

Ruby의 extension.rdoc 문서에 따르면 이렇습니다.

argc는 인수의 개수입니다. argc가 -1이면 함수는 argc, argv, self 세 개의 인수를 받습니다. argc가 -2이면 함수는 self와 args 두 개의 인수를 받으며, args는 메서드 인수를 담은 Ruby 배열입니다.

요약하면 다음과 같습니다.

  • argc가 -1: (int argc, VALUE* argv, VALUE self) 형태로 전체 인수 배열을 직접 받음
  • argc가 -2: (VALUE self, VALUE args) 형태로 인수 배열 하나를 통째로 받음
  • argc가 0 이상: 고정된 개수의 개별 인수를 그대로 받음

따라서 API가 고정된 매개변수 개수의 메서드나 단순한 가변 인수(def foo(*bar)) 하나로만 구성되어 있다면, 여기까지만 읽어도 충분합니다! 하지만 더 풍부한 방식으로 API를 호출하고 싶다면 계속 읽어 주세요.

참고로 rb_define_method에 대한 더 깊이 있는 예제가 필요하다면 Peter Zhu의 'Defining Methods' 글을 읽어보시길 권합니다.

Ruby C API 내부 기능 활용하기

이제 본론으로 돌아가 복잡한 인수 파싱 문제를 다뤄보겠습니다. 다행히 도와줄 도구들이 있습니다. 그 전에 먼저 rb_define_method만 사용했을 때의 한계를 짚어 보겠습니다.

rb_define_method의 한계

블록 인수는 신경 쓰지 않는다

첫 번째 한계는 rb_define_method가 블록 인수를 전혀 다루지 않는다는 점입니다. Ruby 입장에서는 블록이 전달되든 안 되든 문제되지 않기 때문에 애초에 고려 대상이 아닙니다. 대신 rb_block_given_prb_need_block을 사용하면 블록이 넘어왔는지 확인할 수 있습니다. 이 주제 역시 Peter Zhu의 글에 자세히 나와 있습니다.

인수 개수가 유동적일 수 있다

두 번째 한계는 인수가 가변적일 수 있다는 점입니다. 예를 들어 def foo(bar, *baz)처럼 필수 인수와 가변 인수가 섞인 API를 원한다면 인수를 직접 파싱해야 합니다. 이때 rb_check_arity를 -1 버전의 rb_define_method와 함께 사용하면 도움이 됩니다.

키워드 인수

마지막으로 살펴볼 한계는 바로 키워드 인수입니다. 이 글의 핵심 주제이기도 하죠. 키워드 인수를 올바르게 파싱하려면 먼저 이를 꺼내 와야 하는데, 다행히 Ruby C API에는 그래 주는 rb_scan_args라는 메서드가 있습니다.

rb_scan_args에는 rb_define_method가 넘겨준 argcargv, 그리고 인수를 어떻게 해석할지 알려주는 포맷 문자열(fmt), 마지막으로 결과값을 담을 변수(수신자)를 전달합니다. 이것으로 Ruby 인수의 모든 복잡성이 한 줄로 처리됩니다! 물론, 거의요.

fmt의 공식적인 작성 규칙은 extension.rdoc을 참고하시고, 아래 예제에서 일부를 함께 살펴보겠습니다.

함수 작성하고 파싱하기: 예제

이제부터 이 글 전체에서 다음과 같은 함수를 작성한다고 가정해 보겠습니다.

def voronoi_diagram(envelope, *polygons, **kwargs)

이를 rb_scan_args로 파싱하면 다음과 같습니다.

rb_scan_args(argc, argv, "1*:", &envelope, &polygons, &kwargs);

여기서 "1*:"라는 다소 낯선 문자열의 의미는 다음과 같습니다.

  • 1: 필수 위치 인수 1개
  • *: 개수 제한 없는 선택적 위치 인수
  • :: 마지막에 키워드 인수가 온다는 표시

키워드 인수 파싱하기

메서드 시그니처를 어느 정도 제약했지만, 아직 끝난 게 아닙니다. 현재 API는 def voronoi_diagram(envelope, *polygons, **kwargs) 형태입니다.

마지막으로 키워드 인수를 rb_get_kwargs로 파싱해야 합니다. 먼저 필수 인수와 선택 인수를 정하고, table에 인수 이름들을 지정한 뒤 결과를 배열(values)에 저장하면 됩니다.

ID kwargs[] = {rb_intern("style"), rb_intern("color"), 0};
VALUE values[2];
rb_get_kwargs(kwargs_table, kwargs, 0, 2, values);

이것으로 끝입니다! 복잡한 Ruby 메서드를 오로지 C만으로 파싱했습니다. 다만 이 과정이 너무 번거롭게 느껴진다면, 다른 선택지도 있습니다.

Ruby 인터페이스 사용하기

문제를 해결하는 또 다른 방법은 Ruby 문법을 그대로 활용해 Ruby 단계에서 인수를 파싱하는 것입니다.

def voronoi_diagram(envelope, *polygons, **kwargs)
  _voronoi_diagram(envelope, polygons, kwargs)
end
ruby2_keywords :voronoi_diagram

이렇게 하면 rb_define_method의 세 번째 형태를 곧바로 사용할 수 있고, 대응하는 C 메서드는 다음처럼 단순해집니다.

static VALUE
c_voronoi_diagram(VALUE self, VALUE envelope, VALUE polygons, VALUE kwargs)
{
  // ...
}

이렇게 하면 문제를 우아하게 피할 수 있으며, 실제로 Ruby 구현체의 일부 메서드(Primitive 클래스)에서도 이 방식이 사용됩니다.

MRI가 사용하는 클래스는 상당히 복잡하고 C 코드를 자동 생성하기까지 하지만, 우리는 그 아이디어에서 영감을 얻을 수 있습니다. 객체를 하나 만들어 메서드를 연결하면, API 사용자에게 c_voronoi_diagram 같은 내부 구현이 드러나지 않게 할 수 있습니다.

RGeo 코드베이스에서 이 패턴의 실제 활용 사례를 확인해 보실 수 있습니다.

인수 파싱: 어떤 방법을 선택해야 할까?

이번 글에서는 C로 작성된 복잡한 Ruby API를 구성하는 두 가지 방법을 살펴봤습니다. 다시 정리하면 다음과 같습니다.

  • rb_define_method로 정의하고 rb_scan_args로 파싱하기
  • Ruby 인터페이스를 통해 파싱하기

두 방식의 성능을 비교해 보면 사실상 동등합니다. (제 M1 환경에서는 Ruby 쪽 파싱이 평균 1.02배 빨랐습니다.) 사실상 차이가 없다고 봐도 무방합니다.

RGeo 라이브러리의 초기 설계는 가변 길이 인수만 사용하는 API였습니다. 키워드도, 블록도 없었죠. 이건 상당히 제한적인 선택이었고, 지금은 Primitive 방식을 도입해 더 유연한 인수 구성을 지원하고 있습니다.

Ruby 인터페이스 사용의 장점

여러 가지 이유로 저는 Ruby 인터페이스를 사용할 것을 권합니다.

  • 코드량이 적습니다
  • 변경과 유지보수가 쉽습니다

결국 코드베이스를 읽는 경험이 한결 단순해집니다. Ruby 사용자가 자신이 사용하는 젬의 소스 코드를 읽도록 만드는 것은 저에게 중요한 가치입니다. Ruby가 읽기 쉬운 언어인 만큼, 젬 역시 읽기 쉬워야 한다고 믿습니다.

rb_define_method 사용의 장점

반면 C 버전은 유용한 내부 메서드들을 경험할 기회를 줍니다. 예컨대 rb_check_arity는 여전히 정말 유용하며, 블록을 다루는 메서드들도 훌륭합니다. 블록 처리에는 굳이 Ruby 파사드가 필요 없을 수도 있습니다.

결국 자신의 사용 사례에 가장 잘 맞는 선택을 찾는 것이 중요합니다.

마무리

이번 글에서는 Ruby C 확장에서 인수를 파싱하는 두 가지 방법, 즉 rb_define_method(그 한계도 간단히 살펴봤습니다)와 rb_scan_args를 조합하는 방식, 그리고 Ruby 인터페이스를 활용하는 방식을 알아봤습니다.

C 확장에 대해 더 읽고 싶다면 다음 자료를 추천합니다.

  • Building a Ruby C Extension From Scratch
  • A Rubyist's Walk Along the C-side
  • Working with ruby C extensions on Mac

마지막으로 드리는 조언은 RGeo를 확인해 보라는 것입니다. 활발히 개발 중이며 폭넓게 사용되는 C 확장 코드베이스로, 이 글의 예제 대부분도 여기서 가져왔습니다.

즐거운 코딩 되세요!

P.S. Ruby Magic의 글을 발행 즉시 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하고 어떤 글도 놓치지 마세요!

Ruby C 확장 인수 파싱 완전 정복하기: 단계별 가이드

Ulysse Buonomo

게스트 저자인 울리스 부오노모(Ulysse Buonomo)는 업계에서 Ruby 개발자로 일했던 경험이 있으며, 지금은 대부분의 시간을 세계 여행에 보내고 있습니다. 여가 시간에는 RGeo와 Ruby에 몰두하며, Ruby의 내부 구조를 만져 보는 것을 즐깁니다.