Nim作为一门胶水语言, 像极了当年作为一种全栈语言大杀四方的Python. 然而对其特色的跨语言调用功能的描述却散落在不同的文档中, 缺乏有效的整理. 本文尝试整理一下Nim与C/C++互操作的用法.

在不使用3D引擎的情况下开发一个OpenGL程序可以用到许多库, 有C的也有C++的. 所以本文就以OpenGL开发为例进行整理.

第一个例子: GLFW[1]

首先是窗口和上下文管理. 一个最简的使用GLFW显示窗口的程序可能长这样:

#include <GLFW/glfw3.h>

#include <iostream>

void processInput(GLFWwindow *window);

int main()
{
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif

    GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL", NULL, NULL);
    if (window == NULL)
    {
        std::cout << "Failed to create GLFW window" << std::endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);

    while (!glfwWindowShouldClose(window))
    {
        processInput(window);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    glfwTerminate();
    return 0;
}

void processInput(GLFWwindow *window)
{
    if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}

编译和链接代码需要给编译器传入正确的参数, 来找到头文件和库文件, 以及控制编译器的行为. Nim中通过指定pragma实现:

  • passC[2]: 传递给编译器的参数
  • passL[3]: 传递给链接器的参数
  • link[4]: 静态链接

这里我们使用静态链接的方式:

# glfw.nim
{.passC: "-Ilib/glfw/include",
  link: "lib/glfw/lib/libglfw3.a".}

在Mac上还需要链接额外的库:

when defined(macos) or defined(macosx):
  {.passL: "-framework Cocoa",
    passL: "-framework IOKit".}

然后就需要通过importc[5]和header[6] pragma, 让Nim可以调用到C中的函数, 以及使用C中定义的常量等.

常量

C中的常量, 可以直接importc为Nim中的不可变量. 注意对于C中通过define定义的常量, Nim中对应的变量需要一个类型:

const GLFW = "GLFW/glfw3.h"

let GLFW_CONTEXT_VERSION_MAJOR* {.importc, header: GLFW.}: cint

枚举类型因为具体取值也是整型, 所以也可以用这种方式声明.

函数

常用的函数相关的pragma有:

  • cdecl[7]: 指定使用C编译器的调用约定
  • varargs[8]: 指定函数接受可变参数. 就算C函数不接受可变参数也可以这么写, 这样声明时就可以不用显式写出一个个参数. 但显然这样就无法提前让Nim编译器发现传参错误, 只能等到C编译器去发现.

于是要声明C中的函数可以像这样写:

proc glfwGetFramebufferSize*() {.importc, cdecl, varargs, header: GLFW.}
proc glfwGetKey*() {.importc, cdecl, varargs, header: GLFW.}: cint

使用varargs的另一个好处是string类型会被自动转换成cstring, 所以通过这种方式声明的函数在调用时可以直接传入string, 比如:

let colorLocation = glGetUniformLocation(shaderProgram, "color")

结构体

由于Nim中的object对应的就是C中的结构体, 所以C中的结构体直接importcobject就能用, 比如:

type GLFWwindow* {.importc, header: GLFW.} = object

指针

在Nim中声明带类型的指针, 可以用ptr Tptr[T], 两者是等价的.

由于在GLFW中, GLFWwindow相关函数的参数和返回值都是GLFWwindow *, 那么给ptr GLFWwindow定义个别名就能少打些字, 让Nim中的GLFWwindow等同于C中的GLFWwindow *. 像这样:

type
  GLFWwindowObj {.importc: "GLFWwindow", header: GLFW.} = object
  GLFWwindow* = ptr GLFWwindowObj

进一步简化代码

当有一大段声明都使用相同的pragma时, 可以通过pushpop[9]来简化, 比如:

{.push importc, cdecl, varargs, header: GLFW.}
proc glfwCreateWindow*(): GLFWwindow
proc glfwGetFramebufferSize*()
proc glfwGetKey*(): cint
proc glfwGetProcAddress*(): pointer
proc glfwGetTime*(): cdouble
{.pop.}

最终结果

在声明完所用到的C函数和常量之后, 上面的C程序用Nim来写就是这个样子:

import glfw

proc processInput(window: GLFWwindow)

proc main() =
  glfwInit()
  glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3)
  glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3)
  glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE)

  when defined(macos) or defined(macosx):
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE)

  let window = glfwCreateWindow(800, 600, "OpenGL", nil, nil)
  if window == nil:
    echo "Failed to create GLFW window"
    glfwTerminate()
    quit(-1)

  glfwMakeContextCurrent(window)

  while not glfwWindowShouldClose(window):
    processInput(window)

    glfwSwapBuffers(window)
    glfwPollEvents()

  glfwTerminate()

proc processInput(window: GLFWwindow) =
  if glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS:
    glfwSetWindowShouldClose(window, true)

when isMainModule:
  main()

可以看到代码风格类似Python脚本, 然而Nim编译器会将其转换为C代码后再编译和链接为可执行文件. 这也是Nim早期的宣传类似“Python的开发效率, C的执行速度”的由来. (然而实际用下来就会发觉Nim有着各种各样的怪癖和小毛病, 注定无法被大众所接受. 2.0版本的发布说明中甚至写道”Nim is a programming language that is good for everything, but not for everybody”[10]. 有机会再展开了.)

以上就是一个完整的Nim使用C的库的例子. 限于篇幅, 接下来的例子就按照使用场景来整理, 不再提供完整的程序了.

编译源文件

除了上面提到的动态链接和静态链接外, 也可以使用compile[11]来直接编译源文件, 例如配置完GLAD[12]后得到的源文件:

const GLAD = "glad/glad.h"
{.passC: "-Ilib/glad/include",
  compile: "lib/glad/src/glad.c".}

指针

C中的函数要改变传参的取值的话, 参数需要是指针类型. 例如:

int success;
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

Nim中的addr对应了取址操作符&, 所以可以这样写:

var success: cint
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, addr(success));

字符串指针

上面提到使用varargs声明的函数, 传入的string会被自动转换成cstring. 但在需要修改string的内容时编译器会警告. 比如这样的情况:

char infoLog[512];
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);

这时就需要用显示类型转换:

let infoLog = newString(512).cstring
glGetShaderInfoLog(vertexShader, 512, nil, infoLog)

函数指针

GLAD中有需要传入函数指针的情况, 比如:

if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {

Nim中的pointer就相当于void *, 所以可以这样声明:

type GLADloadproc* {.importc, header: GLAD.} = pointer

proc gladLoadGLLoader*() {.importc, cdecl, varargs, header: GLAD.}: cint

这样上面的C代码对应的Nim代码就可以写成:

if gladLoadGLLoader(cast[GLADloadproc](glfwGetProcAddress)) == 0:

数组

C中的数组和指向数组第一个元素的指针是一样的, 所以需要传数组的时候也可以传指针. 比如:

unsigned int VAO;
glGenVertexArrays(1, &VAO);

可以写成:

var VAO: uint32
glGenVertexArrays(1, addr(VAO))

当然直接传数组也可以, 甚至不需要取址:

var VAOs: array[2, uint32]
glGenVertexArrays(2, VAOs)

如果是传seq类型, 那么可以传第一个元素的地址:

let vertices = @[
  #...
].mapIt it.float32
glBufferData(GL_ARRAY_BUFFER, vertices.len * sizeof(cfloat), addr(vertices[0]), GL_STATIC_DRAW)

类型大小

Nim中的sizeof和C中的用法一样, 只是类型必须是对应的C类型. 比如:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);

就可以写成:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(cfloat), cast[pointer](0))

C++

由于C和C++是不同的语言, 所以对应的例子也需要分开讲. 首要的不同在于编译时的参数. 编译调用C库的Nim程序时使用nim c, C++时使用nim cpp. 此外, importc要改为importcpp[13].

命名空间

命名空间[14]需要在importcpp时指定, 比如GLM[15]中的类型:

type Vec3* {.importcpp: "glm::vec3", header: GLM.} = object
  x*, y*, z*: float

函数

即便在importcpp时可以使用@来表示所有剩余的参数, C++函数在声明时也需要写出所有参数. 比如:

proc radians*(deg: cfloat): cfloat {.importcpp: "glm::radians(@)", header: GLM.}

构造函数

构造函数因为调用时的特殊形式, 需要使用constructor[16] pragma. 比如:

proc initVec3*(x, y, z: cfloat): Vec3 {.importcpp: "glm::vec3(@)", constructor, header: GLM.}

运算符重载

Nim中也有运算符重载, 配合importcpp可以调用到C++中对应的版本. 比如:

proc `+`*(a, b: Vec3): Vec3 {.importcpp: "# + #".}

常量指针作为函数返回值

由于Nim中没有常量指针, 所以遇到返回值为常量指针的函数, 不得已只能转换为非常量指针. 比如ASSIMP[17]中的const char * Assimp::Importer::GetErrorString() const:

proc GetErrorString*(self: Importer): cstring
  {.importcpp: "(char *)#.GetErrorString()", header: ASSIMP_IMPORTER.}

继承

Nim 2.0引入了virtual[18] pragma, 使得继承C++中的类以及覆盖类方法成为可能. 例如使用openFrameworks[19]时需要继承ofBaseApp并覆盖其中相应的方法:

class ofApp : public ofBaseApp {
public:
  void setup();
  void update();
  void draw();
}

Nim中就可以这样实现:

type
  ofApp = object of ofBaseApp

proc newOfApp(): ofApp {.constructor: "ofApp(): ofBaseApp()".} =
  discard

proc setup(self: ofApp) {.virtual.} =
  discard

proc update(self: ofApp) {.virtual.} =
  discard

proc draw(self: ofApp) {.virtual.} =
  discard

小结

以上整理的情况应该能覆盖大部分与C/C++互操作的情况. 对于小规模的调用, 这样手动声明绑定的方式就够用了, 且不会引入其他依赖. 但对于大规模的库调用, 最好能找到现成的绑定或是自动生成绑定. 比如OpenGL的场合就可以使用nimgl[20].

另一个需要注意的点是内存管理. Nim中的ptrpointer是跳过了GC需要自行管理内存的, 然而也是与C/C++互操作时不可或缺的. 需要自行管理的东西多了, 其实就是没有发挥出Nim的优势, 实际使用时也需要权衡.

参考