제8장 외부 함수 인터페이스

제8장 외부 함수 인터페이스 (Foreign Function Interface)

외부 함수 인터페이스, 줄여서 FFI는 여러분이 아는 다른 언어의 함수를 Haskell에서 불러 오고, 반대로 Haskell로 짠 함수를 다른 언어 쪽에서 쓸 수 있게 해 주는 다리 같은 존재예요. 이 장은 그 FFI가 정확히 어떤 문법과 의미를 갖는지 정의해요.

출처: https://www.haskell.org/onlinereport/haskell2010/haskellch8.html

도입

Haskell 프로그램을 혼자서만 쓰는 경우는 생각보다 드물어요. 기존에 C로 짜여진 라이브러리를 그대로 활용하고 싶을 수도 있고, 반대로 C나 다른 언어에서 Haskell로 구현한 함수를 호출해야 할 때도 있죠. 이 장에서 다루는 외부 함수 인터페이스(Foreign Function Interface, FFI)는 바로 이 두 방향의 연결을 규격으로 정의해 둔 것이에요. 말 그대로 "외부(foreign)" 세계와 Haskell이 소통하는 창구라고 보면 돼요.

본문

외부 함수 인터페이스(FFI)는 두 가지 목적을 갖고 있어요. (1) 외부 언어 기능에 대한 인터페이스를 Haskell로 기술할 수 있게 하고, (2) 외부 코드에서 Haskell 루틴을 사용할 수 있게 해요. 더 일반적으로 말하면, Haskell과 다른 언어를 섞어서 프로그램을 구현하되, 소스 코드가 서로 다른 Haskell 구현과 비-Haskell 시스템을 가로질러 이식 가능하고, 아키텍처와 운영체제에도 독립적이도록 만드는 것이 그 목표예요.

8.1 외부 언어 (Foreign Languages)

현재 Haskell FFI는 C 호출 규약(calling convention)을 따르는 외부 코드와 Haskell 코드 사이의 상호작용만 규격으로 정해요. 다만 그 설계는 C++이나 Java 같은 다른 프로그래밍 언어의 호출 규약까지 포괄하도록 모듈식으로 확장 가능하게 되어 있어요. 그 언어들에 대한 구체적인 지원 정의는 언어의 이후 버전에 포함될 예정이고요. 두 번째로 빠져 있는 큰 조각은 외부 언어에서의 멀티스레딩과의 상호작용, 특히 스레드 지역 상태(thread-local state) 처리에 대한 정의인데, 이 부분은 현재 구현 정의(implementation-defined) 사항으로 남겨 두고 있어요.

이 규격의 핵심은 Haskell과 함께 쓰이는 외부 언어가 무엇이든 그 자체로는 독립적이에요. 하지만 FFI 규격이 언어별로 달라져야 하는 영역이 두 군데 있어요. (1) 외부 이름(external names)의 규격과 (2) 외부 언어의 **기본 타입 마샬링(marshalling of the basic types)**이 그거예요. 전자의 예를 들면, C에서는 단순한 식별자만으로 객체를 식별하기에 충분한데, Java는 일반적으로 오버로딩을 해소하기 위해 인자 타입과 결과 타입을 함께 쓴 한정된 이름(qualified name)이 필요해요. 후자에 관해서는, 많은 언어가 몇몇 기본 타입의 정확한 표현을 규정하지 않는다는 점을 생각해 보면 돼요. 예를 들어 C의 int는 타입의 크기가 고정되어 있지 않고, 이식 가능한 구현에서는 보통 [−2²⁹, 2²⁹−1](6.4절) 범위의 특정한 값을 갖도록 제한되죠. 그 결과로 C의 int 값을 Haskell에서 안정적으로 나타내려면, int의 표현과 일치함이 보장되는 새 타입 CInt를 도입해야 해요. 외부 언어에 의존하는 기본 타입의 마샬링 규격은 8.6절에서 다뤄요.

8.2 컨텍스트 (Contexts)

**Haskell 컨텍스트(context)**란 Haskell 시스템이 기반하고 있는 실행 환경, 즉 그 추상 머신의 힙(heap), 스택, 그리고 레지스터와 그것들이 구체적 아키텍처에 매핑된 방식을 모두 포함하는 실행 환경을 말해요. 그 밖의 다른 실행 컨텍스트는 모두 **외부 컨텍스트(external context)**라고 불러요. 일반적으로 Haskell 컨텍스트와 주어진 외부 컨텍스트 사이에는, Haskell이 명시적으로 특정 데이터 형식을 규정한 경우를 제외하고는, 데이터 형식이나 호출 규약 간의 어떤 호환성도 가정할 수 없어요.

외부 함수 인터페이스의 핵심 목표는 바로 Haskell 컨텍스트와 외부 컨텍스트 사이에 프로그램 가능한 인터페이스를 제공하는 거예요. 그 결과 Haskell 스레드는 외부 컨텍스트의 데이터에 접근하고 외부 컨텍스트에서 실행되는 함수를 호출할 수 있고, 그 반대 방향도 마찬가지로 가능해져요. 이 정의의 나머지 부분에서 외부 컨텍스트는 **호출 규약(calling convention)**에 의해 식별돼요.

8.2.1 언어 간 타입 일관성 (Cross Language Type Consistency)

많은 외부 언어가 정적 타입 체계를 지원한다는 점을 감안하면, 외부 함수에 대해 외부 언어의 타입과의 일관성을 강제할 수 있겠죠. 하지만 아쉽게도 이는 일반적으로는 불가능해요. Haskell 시스템 구현자(즉, 전용 타입 검사기를 구현한 경우)의 상당한 투자 없이는 말이죠. 예를 들어 C 호출 규약의 경우, 유일한 대안은 Haskell 타입에서 C 프로토타입을 생성해 C 컴파일러가 이 프로토타입을 import하려는 함수용 C 헤더 파일에 명시된 프로토타입과 맞추도록 맡기는 것이에요. 그런데 Haskell 타입에는 이 방식을 쓰는 데 필요한 정보가 일부 빠져 있어요. 특히 Haskell 타입에는 언제 const 수식어를 내보내야 하는지에 대한 정보가 전혀 없죠. 그래서 이 정의는 Haskell 시스템이 외부 타입과의 일관성 검사를 요구하지 않아요. 그럼에도 Haskell 시스템은 합리적인 노력으로 구현할 수 있는 언어 간 일관성 검사가 있다면 제공하도록 권장돼요.

8.3 어휘 구조 (Lexical Structure)

FFI는 단일 키워드 foreign과, 외부 선언 내에서 특별한 의미를 갖는 특수 식별자(special identifiers) 집합을 예약해요. 이 특수 식별자들은 foreign 선언 안에서만 특별한 의미를 갖고, 그 밖의 곳에서는 일반 식별자로 쓰일 수 있어요. 특수 식별자는 ccall, cplusplus, stdcall, jvm, dotnet, safe, unsafe, wrapper, dynamic이에요. 이 이름들은 이 규격(혹은 이 규격의 배경 지식)에서 다루는 호출 규약들의 이름을 담당해요. 다만 구체적인 FFI 구현은 여기에 명시되지 않은 시스템 특정 호출 규약을 추가로 지원할 자유가 있어요.

외부 C 컨텍스트의 객체를 가리키기 위해, 우리는 다음 표기법을 도입해요. desc는 아래 문법에 따라 생성되는데, 이는 C 식별자에 허용되는 것과 동일해요([9] 참조).

chname  →  { chchar } . h          ( C 헤더 파일 이름 )
cid     →  letter { letter | ascDigit }   ( C 식별자 )
chchar  →  letter | ascSymbol ⟨ & ⟩
letter  →  ascSmall | ascLarge | _

8.4 외부 선언 (Foreign Declarations)

외부 선언의 문법은 다음과 같고, 각 문법 생산 규칙은 FFI의 문법적 뼈대를 보여줘요.

topdecl  →  foreign fdecl
fdecl    →  import callconv [ safety ] impent var :: ftype    ( 변수 정의 )
         |  export callconv expent var :: ftype                ( 변수 노출 )
callconv →  ccall | stdcall | cplusplus                        ( 호출 규약 )
         |  jvm | dotnet
         |  시스템 특정 호출 규약
impent   →  [ string ]
expent   →  [ string ]
safety   →  unsafe | safe

import 선언은 외부 컨텍스트의 객체를 Haskell 컨텍스트 안에서 사용할 수 있게 해 주고, 반대로 export 선언은 Haskell 컨텍스트의 함수를 그 객체가 있던 외부 컨텍스트의 외부 개체로 정의해 줘요. 그래서 두 선언은 서로 달라요. import 선언은 새 변수를 정의하는 반면, export 선언은 Haskell 모듈에서 이미 정의된 변수를 사용하기 때문이에요.

외부 개체가 있는 외부 컨텍스트는 foreign 선언에 주어진 호출 규약에 의해 결정돼요. 그 결과 외부 개체의 명세의 정확한 형태는 호출 규약과, 그것이 import 선언(impent로)에 나타나는지 export 선언(expent로)에 나타나는지에 따라 달라져요. 서로 다른 호출 규약이 있어도 문법적으로 균일하게 만들기 위해, 외부 개체의 기술이 어휘적으로 Haskell 문자열 렉심(string lexeme)으로 나타난다는 것이 보장돼요. 유일한 예외는 이 문자열이 빈 문자열(즉 "" 형태)일 때인데, 이 경우 문자열 전체를 생략할 수 있어요.

8.4.1 호출 규약 (Calling Conventions)

주어진 아키텍처에서 외부 개체에 대한 **이진 인터페이스(binary interface)**는 그 개체가 구현된 프로그래밍 언어보다는, 그 개체가 컴파일된 시스템에 더 크게 의존하는 경우가 보통이에요. 호출 규약이 시스템에 얼마나 지배되는지 보여주는 예로, Java Virtual Machine(JVM)[11]용 바이트 코드로 컴파일된 개체는 그것이 구현된 소스 언어(예: Oberon)의 규칙이 아니라 JVM의 규칙으로 호출해야 한다는 점을 생각해 볼 수 있어요.

모든 Haskell FFI 구현은 적어도 ccall로 표기되는 C 호출 규약을 반드시 구현해야 하고, 그 외의 호출 규약은 선택 사항이에요. 일반적으로 호출 규약의 집합은 열려 있어서(open), 개별 구현이 추가 호출 규약을 지원하기로 선택할 수 있어요. ccall에 더해, 표 8.1은 일반적인 호출 규약에 쓰이는 다양한 식별자를 규정해요.

표 8.1: 식별자와 이에 대응하는 호출 규약

식별자 나타내는 호출 규약
ccall 시스템에서 표준 C 컴파일러의 호출 규약
cplusplus 시스템에서 표준 C++ 컴파일러의 호출 규약
dotnet .NET 플랫폼의 호출 규약
jvm Java Virtual Machine의 호출 규약
stdcall Win32 API의 호출 규약 (Pascal 규약과 일치)

다른 호출 규약에 대해서는 구현이 적절한 이름을 자유롭게 고를 수 있어요. 이 규격에서 호출 규약의 의미론적 의미(의미)는 ccallstdcall에 대해서만 정의돼요. 이 두 호출 규약은 서로 다른 의미를 갖진 않아요. 이 두 규약은 표면상 동일한 의미를 갖되, 생성되는 코드만 달라요(8.5.2절 참조). 앞으로 지금은 정의되지 않은 의미를 갖는 다른 호출 규약들(예: cplusplus, jvm, dotnet)은 Haskell의 미래 버전에서 추가될 수 있어요.

한 가지 짚고 넘어갈 점은, Haskell 시스템이 특정 호출 규약을 구현하기 위해 생성하는 코드가 그 시스템이 생성하는 대상 코드와 크게 달라질 수 있다는 거예요. 예를 들어 jvm 호출 규약은 Java 코드를 생성하는 Haskell 컴파일러에게는 사소하게 구현되겠지만, C 코드를 생성하는 컴파일러라면 Java Native Interface(JNI)[10]를 대상으로 삼아야 해요.

8.4.2 외부 타입 (Foreign Types)

다음 타입들이 **기본 외부 타입(basic foreign types)**의 집합을 이루고, 이 타입들만이 인자와 결과로서 외부 컨텍스트 간에 "안전하게" 전달될 수 있어요. 외부 타입은 다음 문법으로 생성돼요.

ftype   →  frtype
        |  fatype → ftype
frtype  →  fatype
        |  ()
fatype  →  qtycon atype₁ … atypeₖ      ( k ≥ 0 )

외부 타입은 at₁ -> … -> atₙ -> rt 형태이고 여기서 n ≥ 0이에요. 이는 외부 개체의 arity(인자의 개수)를 암시해요. 마샬링 가능한 외부 결과 타입(marshallable foreign result types) rt는 1장과 6장에서 정의된 **접근 가능한 타입(accessible type)**들로 구성돼요. 그런데 이 타입 정의는 한계가 하나 있어요. 이 정의는 타입을 생성(newtype)하는 방식으로 이루어지는데, 타입 클래스 Storable(37절)의 인스턴스는 granularity 때문에 취급하지 못하죠.

외부 개체는 기본적으로 불투명한(opaque) 것이어서, 외부 컨텍스트와 Haskell 컨텍스트 사이에서 표준적으로 전송될 수 있는 타입은 제한된 집합뿐이에요. 외부 타입은 at₁ -> … -> atₙ -> rt(여기서 n ≥ 0) 형태고, 이는 외부 개체의 arity가 n임을 뜻해요. 여기서 각 atᵢ는 **마샬링 가능한 외부 인자 타입(marshallable foreign argument type)**이어야 해요. 정확히 말해, 마샬링 가능한 타입들의 집합은 접근 가능한 타입들에 **유형 생성자(type constructor)**를 적용해서 만들 수 있는 타입들로, 여기서 유형 생성자들은 명시적으로 강제할 수 있는 타입이거나, 다음 두 경우 중 하나여야 해요.

어떤 타입 t를 주위에 두고 고려해 보면, t가 실제로 다음과 같은 형태의 타입 약칭(type synonym)의 확장으로 얻을 수 있을 때에만 그 타입이 마샬링 가능해요.

newtype T a₁ … aₙ = N t

즉, 외부 타입은 그 데이터 생성자 NT a₁ … aₙ에 대해, 그리고 t가 마샬링 가능한 타입이라는 조건 아래에서 마샬링 가능해요. 외부 타입을 정의하는 모듈이 그 타입을 추상적으로 내보내는 경우(즉 생성자를 내보내지 않는 경우)에는 그 타입이 마샬링 가능하지 않다는 점을 명심해야 해요. C 타입에 대한 Haskell 대응 타입을 정의하는 모듈 Foreign.C.Types(28장)가 바로 이 관례를 따르죠.

마샬링 가능한 외부 결과 타입. 결과 타입 rt는 내포형 타입(nested type)을 가질 수 없다는 점만 빼면 인자 타입과 동일한 조건을 충족해요. 특히 결과 타입이 IO ()처럼 ()를 포함하는 경우가 일반적이지만, ()는 인자로는 허용되지 않아요. 또한 인자 타입은 PtrFunPtr 같은 포인터 타입을 포함할 수 있는데, 이런 타입들은 인자와 결과 양쪽에서 허용되지만 결과 타입에서는 특별한 제약을 갖지 않아요.

8.4.3 import 선언 (Import Declarations)

일반적인 import 선언의 형태는 foreign import c e v :: t이에요. 이 선언은 호출 규약 c로 문자열 e가 식별하는 외부 개체를 실행해 v를 정의하고, v의 타입이 t라고 선언해요. e의 정확한 형태는 호출 규약 c에 따라 달라지고, 8.5절에서 정의해요. import 선언이 주어지면 동일한 모듈에서 v에 대한 다른 최상위 선언은 허용되지 않아요.

예를 들어 선언

foreign import ccall "string.h strlen"
    cstrlen :: Ptr CChar -> IO CSize

은 문자열 "string.h strlen"으로 식별되는 C 함수 strlen을 Haskell 변수 cstrlen으로 정의해요.

일부 외부 개체는 **순수 함수(pure function)**로 import될 수 있어요. 예를 들어

foreign import ccall "math.h sin"
    sin :: CDouble -> CDouble.

여기서 C 함수 sin은 순수 함수이고, 외부 컨텍스트는 Haskell 컨텍스트와 열적으로(열역학적으로) 상호작용하지 않기 때문에 항상 같은 결과를 만들어 내요. 특정 형태의 외부 개체가 Haskell 타입에 제약을 두는지 여부는 8.5절에서 정의되죠. 일부 형태의 외부 개체는 허용되는 Haskell 타입의 집합을 제한하지만, 시스템은 일반적으로 import 선언에 주어진 Haskell 타입과 외부 개체의 인자·결과 타입 사이의 일관성을 보장할 수 없어요. 그 일관성을 확보하는 건 프로그래머의 책임이에요.

선택적으로, import 선언은 호출 규약 뒤에 외부 개체를 호출할 때 사용해야 할 **안전성 수준(safety level)**을 지정할 수 있어요. safe 호출은 덜 효율적이지만 Haskell 시스템이 외부 코드에서의 콜백을 허용할 수 있는 상태로 남아 있음을 보장해요. 반대로 unsafe 호출은 오버헤드가 적지만 Haskell 시스템으로의 콜백을 유발해서는 안 되고, 만약 그런다면 시스템 동작은 정의되지 않아요(undefined). 호출의 기본값은 safe예요. 콜백이 Haskell 시스템으로 들어오면 외부 개체가 호출된 후 그 호출이 반환되기 전에 가비지 컬렉션이 촉발될 수 있다는 점에 주의해야 해요. 따라서 안정 포인터(stable pointer, 36절 참조) 이외의 객체들은 스토리지 관리자에 의해 이동되거나 수집될 수 있어요.

8.4.4 export 선언 (Export Declarations)

일반적인 export 선언의 형태는 foreign export c e v :: t이에요. 이 선언은 v가 동일한 모듈의 최상위에 정의되어 있거나 import되어 있어야 해요. 그리고 Haskell 시스템은 문자열 e로 기술되는 외부 개체를, 호출 규약 c를 사용하는 외부 코드가 사용할 수 있게 정의해요. 외부 개체 e에 대한 외부 호출은 v의 평가로 번역돼요. 타입 tv의 타입의 인스턴스여야 해요. 예를 들어 다음과 같이 쓸 수 있어요.

foreign export ccall "addInt"   (+) :: Int   -> Int   -> Int
foreign export ccall "addFloat" (+) :: Float -> Float -> Float

foreign export 처리 중에 발생한 예외는 Haskell 시스템의 정상적인 동작이 아니므로 시스템 동작이 정의되지 않아요. 그래서 Haskell 예외는 Haskell 안에서 잡아서 외부 코드에 명시적으로 마샬링해야 해요.

8.5 외부 개체의 명세 (Specification of External Entities)

모든 foreign 선언은 호출하는 외부 개체를 명시해야 해요. 외부 개체를 유일하게 결정하는 데 필요한 표기법은, 그 개체에 접근하는 호출 규약에 크게 의존해요. 예를 들어 ccall 호출 규약에서는 전역 레이블(global label)이면 충분해요. 하지만 jvm 호출 규약에서는 메서드를 유일하게 식별하기 위해 타입 정보를 제공해야 해요. 후자의 경우에는 Java 소스 수준의 타입 문법과 JNI가 기대하는 문법 사이에서 선택을 해야 하는데, 당연히 외부 개체 명세의 문법은 호출 규약에 따라 달라지고 사소하지 않을 수 있어요.

그래서 FFI는 외부 개체를 나타내는 일반 문법을 고정하지 않고, impentexpent 양쪽 모두에게 그 개체를 식별하는 문자열을 제공할 것을 요구해요. 특정 호출 규약을 구현하는 Haskell 시스템은 이 문자열들을 해당 호출 규약에 따라 파싱해야 해요. impentexpent를 문자열 형태로 정의한다는 것은, Haskell 프로그램을 분석하는 데 필요한 정보와 외국어와 상호작용하는 코드를 생성하는 데 필요한 정보가 분리된다는 뜻이고, 이는 Haskell 소스 코드를 처리하는 도구에 특히 유용해요. impentexpent가 제공하는 개체 정보를 무시하더라도, foreign import/export 선언은 식별자 정의·사용 정보와 타입 정보를 추론하기에 충분해요.

더 복잡한 호출 규약에서는 사용자 수준 문법(Java나 C++의 문법)과 시스템 수준 문법(각각 JNI의 타입 문법이나 맹글링된 C++) 사이에 선택의 여지가 있어요. 그런 선택이 존재한다면, 사용자 수준 문법이 선호돼요. 더 사용자 친화적일 뿐 아니라, 시스템 수준 문법이 외부 언어의 특정 구현에 완전히 독립적이지 않을 수 있기 때문이에요. 아래에서는 ccall 호출 규약에 대해 외부 개체를 명시하는 문법과 그 의미를 정의해요.

8.5.1 표준 C 호출 (Standard C Calls)

다음은 ccall 호출 규약 아래에서 foreign 선언의 외부 개체 구조가 어떻게 정의되는지 보여줘요. import와 export 선언에 대해 각각 따로 정의한 뒤, 외부 함수 타입에 대한 추가 제약 조건을 정의해요.

FFI는 C 함수와 전역 변수에 대한 접근만 다뤄요. 그 외의 C 프로그램 개체에 접근하는 메커니즘은 없어요. 특히 Haskell에서 전처리기 심볼(전처리기 매크로를 포함한 #define 심볼)에 접근하는 것은 지원하지 않는데, 이것은 여기 정의된 일반 FFI에 비해 추가적인 편의 기능으로 취급돼요.

import 선언 (Import Declarations)

import 선언의 경우 외부 개체 명세의 문법은 다음과 같아요.

impent  →  " [ static ] [ chname ] [ & ] [ cid ] "     ( 정적 함수 또는 주소 )
         |  " dynamic "                                 ( 주소를 import하는 스텁 팩토리 )
         |  " wrapper "                                 ( 썽크를 export하는 스텁 팩토리 )

첫 번째 대안은 문자열이 [ static ] [ chname ] [ & ] [ cid ]의 조합이라는 뜻이에요. 여기서 각 괄호는 선택 사항이에요. static이 지정되면 import는 정적 함수나 정적 주소를 뜻해요. cid는 import되는 C 개체의 이름이고, &가 있으면 개체의 주소를, 없으면 값 그 자체를 의미해요. cid가 생략되면 기본값은 import되는 Haskell 변수의 이름이에요. 선택적 파일 이름 chname은 C 헤더 파일을 지정하는데, 의도된 의미는 그 헤더 파일이 cid로 식별되는 C 개체를 선언한다는 거예요. 특히 Haskell 시스템이 Haskell을 C 코드로 컴파일할 때는 생성된 모든 C 파일에 #include "chname" 지시어를 넣어야 해요.

#include " chname "

두 번째와 세 번째 대안은 각각 dynamicwrapper 키워드로 식별되는데, 이들은 Haskell 시스템이 생성해야 하는 **스텁 함수(stub function)**를 나타내요. dynamic의 경우 스텁은 C 함수 포인터를 Haskell 함수로 변환하고, 반대로 wrapper의 경우 스텁은 Haskell 썽크(thunk)를 C 함수 포인터로 변환해요. static·dynamic·wrapper 중 어떤 지정자도 주어지지 않으면 static이 가정돼요. 그런데 dynamic이나 wrapper라는 이름을 가진 C 루틴을 import하려면 static 지정자가 여전히 필요해요.

static import에서 외부 개체 명세는 항상 Haskell 타입이 함수형이 아니더라도 C 함수를 가리켜요. 예를 들어

foreign import ccall foo :: CInt

이 선언은 인자가 없고 정수 값을 반환하는 순수 C 함수 foo를 가리켜요. 여기서 import된 변수 foo는 순수한 타입을 갖지만, 실행 관점에서는 비순수 0항(nullary) 함수를 의미해요. Haskell 프로그램이 정수 타입의 C 변수 bar에 접근해야 한다면,

foreign import ccall "&" bar :: Ptr CInt

이렇게 해서 그 변수를 가리키는 포인터를 얻어야 해요. 그 변수는 Foreign.Storable 모듈(37절)이 제공하는 함수로 접근할 수 있어요.

export 선언 (Export Declarations)

ccall export 선언의 외부 개체는 C 식별자 cid이고, 문법은 다음과 같아요.

expent  →  " [ cid ] "

cid가 생략되면 외부 이름은 기본적으로 export되는 Haskell 변수의 이름이 돼요.

외부 함수 타입에 대한 제약 (Constraints on Foreign Function Types)

import 선언에서 정의되는 변수가 가질 수 있는 허용되는 Haskell 타입의 집합은 다음 규칙들에 의해 제한돼요.

정적 함수 (Static Functions). 정적 함수는 어떤 외부 타입이든 가질 수 있어요. 특히 결과 타입은 IO 모나드에 있을 수도 있고 없을 수도 있어요. 순수하지 않은 함수를 IO 모나드로 import하지 않으면 시스템 동작은 정의되지 않아요. 일반적으로 import되는 레이블의 C 타입과의 일관성에 대한 검사는 수행되지 않아요. 예를 들어 순수 C 함수 system을 import하는 선언을 보면,

foreign import ccall "static stdlib.h" system :: Ptr CChar -> IO CInt

C 함수 system은 순수하지 않으므로(환경에 따라 다른 결과를 만들 수 있으므로) IO 모나드 결과 타입을 사용해요. 마찬가지로, C 변수의 주소를 import하는 경우에는

foreign import ccall "errno.h &errno" errno :: Ptr CInt

&errno처럼 주소를 import하면 그 주소가 가리키는 것은 C 변수 errno이고, 그 변수는 C에서 쓰이는 방식 그대로 접근할 수 있어요.

동적 가져오기 (Dynamic). dynamic 스텁의 타입은 FunPtr ft -> ft 형태이고, 여기서 ft는 임의의 외부 타입이에요. 예를 들어

foreign import ccall "dynamic" mkFun :: FunPtr (CInt -> IO ()) -> (CInt -> IO ())

이렇게 하면 이 스텁 팩토리는 인자를 받아 결과를 반환하지 않는 C 함수 포인터를, 대응하는 Haskell 함수로 감싸 주는 함수를 만들어요.

동적 래퍼 (Dynamic wrapper). wrapper 스텁의 타입은 t -> IO (FunPtr t) 형태이고, 여기서 t는 임의의 외부 타입이에요. 예를 들어

foreign import ccall "wrapper" mkCallback :: IO () -> IO (FunPtr (IO ()))

이렇게 하면 이 스텁 팩토리는 Haskell 함수(썽크)를 C 함수 포인터로 변환해 주는데, 이 포인터는 C 루틴에 전달될 수 있고, C 루틴은 참조된 함수를 호출함으로써 Haskell 컨텍스트로 콜백할 수 있어요.

헤더 파일의 명세 (Specification of Header Files)

import 선언에 지정된 C 헤더는 항상 #include에 의해 포함돼요. Haskell 시스템은 #include < chname > 형태의 포함은 지원하지 않아요. ISO C99[7] 표준은 #include < chname >에 쓰이는 검색 경로가 #include " chname "에도 쓰이고, 이 경로들이 #include " chname "에만 특화된 모든 경로 뒤에 검색된다는 것을 보장해요. 게다가 우리는 외부 개체 명세의 파싱을 모호하지 않게 만들기 위해 chname.h로 끝나기를 요구해요.

include 파일 명세는 의도적으로 최소한으로 유지돼요. 라이브러리는 종종 수많은 include 지시어를 요구하고, 그중 일부는 시스템 의존적일 수 있어요. 가능한 모든 설정을 다루려는 어떤 설계든 상당한 복잡성을 도입하게 돼요. 게다가 현재 설계에서는 표준 C 전처리기 기능을 사용해 관련 헤더를 모두 포함하는 사용자 정의 include 파일을 지정할 수 있어요. 헤더 파일은 foreign 호출의 의미에 아무런 영향이 없고, 구현이 헤더 파일을 사용하는지 여부는 구현 정의(implementation-defined) 사항이에요. 다만 일부 구현은 올바른 코드를 생성하기 위해 외부 함수에 대한 올바른 프로토타입을 제공하는 헤더 파일을 요구할 수 있으므로, 이식 가능한 FFI 코드는 적절한 헤더 파일을 포함해야 해요.

C 인자 승격 (C Argument Promotion)

C의 인자 전달 규약은 호출 지점에서 함수 프로토타입이 범위 안에 있는지 여부에 따라 달라져요. 특히 함수 프로토타입이 범위 안에 없으면, 정수형과 부동소수점형에 **기본 인자 승격(default argument promotion)**이 적용돼요. 일반적으로 Haskell 시스템이 주어진 C 함수가 프로토타입을 범위 안에 둔 상태로 컴파일되었는지 여부를 알 것을 기대할 수 없어요. 따라서 이식성을 위해 우리는 Haskell 시스템이 일반적으로 C 함수 호출과 Haskell 함수용 C 스텁을, 호출되는 함수의 프로토타입이 범위 안에 있는 것처럼 구현하도록 요구해요.

이 관례는 C와 Haskell 코드 사이의 일치를 보장할 책임이 FFI 사용자에게 있다는 뜻이에요. 특히 프로토타입 없이 컴파일된 C 함수를 Haskell에서 호출할 때는, 대응하는 foreign import 선언의 Haskell 시그니처가 승격 후의 타입을 사용해야 해요. 예를 들어 C 함수

void foo (a) float a; { ... }

이렇게 정의되어 있고 (프로토타입 없이) 컴파일되었다고 해 볼게요. 이 함수는 float 인자를 받도록 정의되었지만, 프로토타입이 없으면 C는 모든 부동소수점 인자를 double로 승격해요. 그래서 foofloat가 아니라 double 타입의 값을 받을 것으로 기대하게 돼요. 따라서 올바른 foreign import 선언은 다음과 같아요.

foreign import ccall foo :: Double -> IO ()

프로토타입이 있는 함수의 경우에는 승격이 적용되지 않아요.

void foo (float a);

이 경우에는 올바른 foreign import 선언이 다음과 같아요.

foreign import ccall foo :: Float -> IO ()

이와 유사하게, Haskell → C 방향에서: Haskell 함수를 C에서 호출할 때는 foreign export 선언에 주어진 시그니처와 일치하는 함수 프로토타입이 범위 안에 있어야 해요. 그렇지 않으면 C 컴파일러가 모든 함수 인자에 승격 규칙을 잘못 적용하게 돼요.

가변 인자(variadic)를 받도록 정의된 C 함수에 관해서는, float형 인자만 승격을 겪는다는 점에 유의해야 해요. 하지만 C는 그런 함수들에 대해 호출 규약이 달라질 수 있기 때문에, Haskell 시스템은 일반적으로 가변 인자 함수를 활용할 수 없어요. 그래서 가변 인자 함수의 사용은 이식 가능한 코드에서 권장되지 않아요(비권장, deprecated).

8.5.2 Win32 API 호출 (Win32 API Calls)

stdcall 호출 규약 아래에서 외부 개체의 문법은 ccall에서와 동일해요. ccallstdcall 호출 규약은 오직 생성되는 코드에서만 달라져요.

8.6 마샬링 (Marshalling)

이전 절에서 확장한 언어 확장에 더해, 표준은 외부 함수를 이식 가능하게 사용하고 복합 구조체를 마샬링하는 것을 쉽게 해 주는 일련의 모듈을 규정해요. 일반적으로 Haskell 구조체를 외부 표현으로(또는 그 반대로) 마샬링하는 작업은 Haskell이나 외부 언어 어느 쪽에서든 구현될 수 있어요. 적어도 외부 언어가 C처럼 확연히 저수준일 때는 마샬링을 Haskell에서 하는 데 좋은 이유가 있어요. Haskell의 지연 평가(lazy evaluation) 전략 때문에, 외부 코드는 구조체에 접근하기 전에 그 구조체의 평가를 강제해야 할 텐데, 이는 외부 언어에서 복잡한 코드를 만들지만 Haskell에서 마샬링을 작성할 때는 특별한 고려가 필요 없기 때문이에요.

마샬링 코드는 종종 Haskell 문법 안에서 C처럼 보이기도 하지만, 그러한 코드가 Haskell 타입 시스템의 보호를 받는다는 사실이 중요해요. 타입 시스템은 디버깅하기 어려운 런타임 결함으로 이어질 많은 오류를 여전히 잡아내요. Haskell 데이터 구조에 대한 직접 접근을 외부 언어(특히 C)에 허용하는 경우, 즉 Haskell 구조체를 생성할 때는 힙을 손상시킬 위험이 있고, 이는 보통 디버깅하기 매우 어려운 결함으로 이어져요. 그래서 Haskell FFI는 Haskell 쪽 마샬링을 강조하고, 지원되는 각 언어마다 언어 의존 모듈이 있어야 한다고 규정해요. 특히 표준은 Foreign 모듈과 그 언어 의존 하위 모듈(Foreign.C 등)의 존재를 요구해요.

Foreign.C 같은 모듈은 일반적으로 외부 언어의 기본 타입을 나타내는 Haskell 타입을 제공하는데, 현재 아키텍처의 외부 언어 기본 구현이 제공하는 외부 타입과 호환되는 표현을 사용해요. 이것은 표준이 기본 타입 구현의 일부 측면을 열어 두는 언어(예: C의 여러 정수형 크기가 고정되지 않은 것)에서 특히 중요해요. 그래서 C 인터페이스를 Haskell에서 충실히 나타내려면, C의 각 정수형에 대해 해당 C 타입과 같은 크기를 가짐이 보장되는 정수형이 Haskell에 필요해요.

8.7 외부 C 인터페이스 (The External C Interface)

Haskell 시스템은 C 코드가 Haskell 런타임 시스템과 상호작용할 수 있도록 다음 C 심볼들을 제공해야 해요. 표 8.2는 타입을 나타내는 심볼들과 그들이 나타내는 Haskell 타입, 그리고 그 심볼들을 구현하는 구체적인 C 타입에 부과되는 제약을 나열해요.

표 8.2: C 타입 심볼

C 심볼 Haskell 심볼 구체적인 C 타입에 대한 제약
HsChar Char 정수형
HsInt Int 부호 있는 정수형, ≥ 30비트
HsInt8 Int8 부호 있는 정수형, 8비트; 가능하면 int8_t
HsInt16 Int16 부호 있는 정수형, 16비트; 가능하면 int16_t
HsInt32 Int32 부호 있는 정수형, 32비트; 가능하면 int32_t
HsInt64 Int64 부호 있는 정수형, 64비트; 가능하면 int64_t
HsWord8 Word8 부호 없는 정수형, 8비트; 가능하면 uint8_t
HsWord16 Word16 부호 없는 정수형, 16비트; 가능하면 uint16_t
HsWord32 Word32 부호 없는 정수형, 32비트; 가능하면 uint32_t
HsWord64 Word64 부호 없는 정수형, 64비트; 가능하면 uint64_t
HsFloat Float 부동소수점형
HsDouble Double 부동소수점형
HsBool Bool int
HsPtr Ptr a (void *)
HsFunPtr FunPtr a (void (*)(void))
HsStablePtr StablePtr a (void *)

C 타입 HsT가 Haskell 타입 T를 나타낼 때, foreign 함수 선언에서 T가 나타나는 곳은 대응하는 C 함수 프로토타입에서 HsT와 일치해야 해요. 실제로 Haskell 시스템이 foreign으로 import된 C 루틴을 호출하는 C 코드를 생성할 때는, 그런 프로토타입을 제공하고 외부 C 함수용 외부 개체 문자열에 지정할 수 있는 헤더(8.5.1절 참조)를 통해 포함시켜야 해요. 그렇지 않으면 시스템 동작은 정의되지 않아요. Haskell 값 nullPtr은 C에서 (HsPtr) NULL로 매핑되고, nullFunPtr(HsFunPtr) NULL로 매핑됨이 보장되며 그 역도 마찬가지예요.

표 8.3은 표 8.2의 타입들의 범위와 정밀도를 나타내는 심볼을 담고 있어요. 사용 가능한 곳에서는 대응하는 Haskell 값도 표에 적어 두었어요.

표 8.3: 범위와 정밀도에 대한 C 인터페이스

CPP 심볼 Haskell 값 설명
HS_CHAR_MIN minBound :: Char
HS_CHAR_MAX maxBound :: Char
HS_INT_MIN minBound :: Int
HS_INT_MAX maxBound :: Int
HS_INT8_MIN minBound :: Int8
HS_INT8_MAX maxBound :: Int8
HS_INT16_MIN minBound :: Int16
HS_INT16_MAX maxBound :: Int16
HS_INT32_MIN minBound :: Int32
HS_INT32_MAX maxBound :: Int32
HS_INT64_MIN minBound :: Int64
HS_INT64_MAX maxBound :: Int64
HS_WORD8_MAX maxBound :: Word8
HS_WORD16_MAX maxBound :: Word16
HS_WORD32_MAX maxBound :: Word32
HS_WORD64_MAX maxBound :: Word64
HS_FLOAT_RADIX floatRadix :: Float
HS_FLOAT_ROUND n/a [7] 따른 반올림 방식(rounding style)
HS_FLOAT_EPSILON n/a [7]에 따른 1과 그보다 큰 가장 작은 값의 차이
HS_DOUBLE_EPSILON n/a (위와 동일)
HS_FLOAT_DIG n/a [7]에 따른 십진 자릿수
HS_DOUBLE_DIG n/a (위와 동일)
HS_FLOAT_MANT_DIG floatDigits :: Float
HS_DOUBLE_MANT_DIG floatDigits :: Double
HS_FLOAT_MIN n/a [7]에 따른 최소 부동소수점 수
HS_DOUBLE_MIN n/a (위와 동일)
HS_FLOAT_MIN_EXP fst . floatRange :: Float
HS_DOUBLE_MIN_EXP fst . floatRange :: Double
HS_FLOAT_MIN_10_EXP n/a [7]에 따른 최소 십진 지수
HS_DOUBLE_MIN_10_EXP n/a (위와 동일)
HS_FLOAT_MAX n/a [7]에 따른 최대 부동소수점 수
HS_DOUBLE_MAX n/a (위와 동일)
HS_FLOAT_MAX_EXP snd . floatRange :: Float
HS_DOUBLE_MAX_EXP snd . floatRange :: Double
HS_FLOAT_MAX_10_EXP n/a [7]에 따른 최대 십진 지수
HS_DOUBLE_MAX_10_EXP n/a (위와 동일)
HS_BOOL_FALSE False
HS_BOOL_TRUE True

표 8.3의 C 심볼들 중 HS_FLOAT_ROUND를 제외한 모두가 #if 전처리 지시어에서 사용하기에 적합한 상수예요. 반올림 방식(HS_FLOAT_ROUND)과 진법(radix, HS_FLOAT_RADIX)이 각각 하나뿐이라는 점에 주목할 필요가 있는데, ISO C[7]이 지원하는 것이 이것이 전부이기 때문이에요. 게다가 C 쪽에서 64비트 정수형을 지원하지 않는 구현이라면 HsInt64HsWord64를 (8바이트가 아닌 자체 크기로) 구현해야 해요.

마지막으로, 외부 C 코드가 Haskell 런타임 시스템과 상호작용하기 위해 다음 C 프로토타입들이 제공돼요.

void hs_init     (int *argc, char **argv[]);
void hs_exit     (void);
void hs_set_argv (int argc, char *argv[]);
void hs_perform_gc (void);
void hs_free_stable_ptr (HsStablePtr sp);
void hs_free_fun_ptr    (HsFunPtr fp);

이들은 C로 작성된(그리고 Haskell로 구현된 루틴을 호출하는) 프로그램을 위해 제공돼요. hs_init()은 Haskell 시스템을 초기화하고 사용 가능한 명령줄 인자를 제공해요. 반환 시에 Haskell 런타임 시스템만을 위한 인자는 제거돼요(즉 argcargv가 가리키는 값이 바뀔 수 있어요). 이 함수는 어떤 Haskell 함수가 호출되기 전에 프로그램 시작 시에 호출되어야 해요. 그렇지 않으면 시스템 동작은 정의되지 않아요. 반대로 Haskell 시스템은 hs_exit() 호출로 비활성화(deinitialise)돼요. hs_init()은 각각에 대응하는 동일한 수의 hs_exit() 호출이 따라오고, 첫 번째 hs_exit() 호출이 마지막 hs_init() 호출 이후에만 발생한다는 조건 아래에서 여러 번 호출될 수 있어요. 이는 시스템이 hs_exit()으로 비활성화된 후 나중에 hs_init()으로 다시 초기화될 수 있다는 뜻이에요. 이렇게 함으로써 여러 라이브러리가 Haskell로 구현된 경우 반복적인 초기화가 지원되도록 하죠. Haskell 시스템은 두 번째 및 이후의 hs_init() 호출에 전달된 명령줄 인자는 무시해요.

hs_set_argv()System.Environment 모듈(39절)의 getProgNamegetArgs 함수가 반환하는 값을 설정해요. 이 함수는 hs_init() 이후에만 호출될 수 있어요. 게다가 hs_set_argv()가 호출되면 hs_init()에 전달된 명령줄 인자는 모두 무시돼요. hs_init()hs_set_argv()의 분리는, Haskell 시스템 외에 초기화 중에 명령줄 인자를 처리하는 다른 라이브러리가 함께 쓰이는 상황에서 필수적이라는 점을 기억해 둘 필요가 있어요.

hs_perform_gc()는 Haskell 스토리지 관리자에게 가비지 컬렉션을 수행하도록 조언하는데, 스토리지 관리자는 도달할 수 없는 모든 객체를 해제하려고 노력해요. 이 함수는 Haskell 코드로 unsafe import된 C 함수에서 호출되어서는 안 되고, finalizer에서도 사용되어서는 안 돼요. 마지막으로 hs_free_stable_ptr()hs_free_fun_ptr()은 Haskell 함수 freeStablePointerfreeFunPtr(36절 참조)의 C 대응 함수예요.

더 알아보기 (Learn more)

  • 챕터 6 — 타입과 클래스(Type and Classes): 마샬링 가능한 타입과 타입 일관성의 기초가 되는 타입 개념을 다뤄요.
  • 챕터 28 — Foreign.C.Types: C 타입들의 Haskell 대응 타입을 구현하는 모듈이에요.
  • 챕터 36 — 안정 포인터(Stable Pointers)와 Foreign.Storable: 객체의 수명을 외부 세계와 안전하게 연결하고, C 메모리와 Haskell 값을 주고받는 법을 다뤄요.
  • 다음 장(챕터 9) — 문법 요약을 통해 이 장에서 나온 foreign 문법이 전체 문법에서 어떻게 쓰이는지 확인할 수 있어요.