在 C/C++ 中使用 nanoarrow 入门#

本教程提供了一个简短的示例,展示了如何编写一个 C++ 库,该库公开一个基于 Arrow 的 API 并使用 nanoarrow 来实现一个简单的文本文件读取器/写入器。通常,nanoarrow 可以帮助您编写一个库或应用程序,该库或应用程序可以

  • 公开基于 Arrow 的 API 从数据源或格式中读取数据,

  • 公开基于 Arrow 的 API 将数据写入数据源或格式,

  • 公开一个或多个计算函数,这些函数对以 Arrow 数组形式存在的进行操作并生成数据,以及/或

  • 公开扩展类型实现。

由于 Arrow 在许多语言中都有绑定,这意味着您或其他人可以轻松地在 R、Java、C++、Python、Rust、Julia、Go 或 Ruby 等高级运行时环境中绑定或使用您的工具。

nanoarrow 库不是实现基于 Arrow 的 API 的唯一方法:Arrow C++、Rust 和 Go 都是很好的选择,它们可以编译成可从其他语言以 C 方式链接的静态库;但是,现有的 Arrow 实现会生成相对较大的静态库,并且可能会根据实现和使用的功能提出复杂的构建时或运行时链接要求。如果您正在使用的库集已经提供了您所需的功能,nanoarrow 可能会提供您所需的所有功能。

现在我们已经讨论了为什么您可能想要使用 nanoarrow 构建一个库,那么让我们来构建一个吧!

注意

本教程还介绍了编写 C++ 库的一些基本结构。如果您已经了解如何做到这一点,请随时滚动到下面提供的代码示例,或查看最终示例项目

#

我们将在本教程中编写的库是一个简单的文本处理库,可以分割和重新组装文本行。它将能够

  • 将文本从缓冲区读入 ArrowArray,每行一个元素,以及

  • ArrowArray 的元素写入缓冲区,在每个元素之后插入换行符。

为了便于说明,我们将其称为 linesplitter

开发环境#

有许多优秀的 IDE 可以用于开发 C 和 C++ 库。在本教程中,我们将使用 VSCodeCMake。您需要安装这两个工具才能继续学习:VSCode 可以从官方网站下载,适用于大多数平台;CMake 通常通过您最喜欢的包管理器安装(例如,brew install cmakeapt-get install cmakednf install cmake 等)。您还需要一个 C 和 C++ 编译器:在 MacOS 上,可以使用 xcode-select --install 安装;在 Linux 上,您需要安装提供 gccg++make 的包(例如,apt-get install build-essential);在 Windows 上,您需要安装 Visual Studio 和 CMake,您可以从官方下载页面下载。

安装 VSCode 后,请确保您已安装 **CMake 工具** 和 **C/C++** 扩展。环境设置完成后,创建一个名为 linesplitter 的文件夹,并使用 **文件 -> 打开文件夹** 打开它。

接口#

我们将库的接口公开为一个名为 linesplitter.h 的头文件。为了确保定义在任何给定源文件中只包含一次,我们将在顶部添加以下行

#pragma once

然后,我们需要 Arrow C 数据接口 本身,因为它提供了我们的 API 将构建其上的其他 Arrow 实现所识别的类型定义。它被设计成以这种方式复制粘贴 - 无需将其放在另一个文件中或包含另一个项目的任何内容。

#include <stdint.h>

#ifndef ARROW_C_DATA_INTERFACE
#define ARROW_C_DATA_INTERFACE

#define ARROW_FLAG_DICTIONARY_ORDERED 1
#define ARROW_FLAG_NULLABLE 2
#define ARROW_FLAG_MAP_KEYS_SORTED 4

struct ArrowSchema {
  // Array type description
  const char* format;
  const char* name;
  const char* metadata;
  int64_t flags;
  int64_t n_children;
  struct ArrowSchema** children;
  struct ArrowSchema* dictionary;

  // Release callback
  void (*release)(struct ArrowSchema*);
  // Opaque producer-specific data
  void* private_data;
};

struct ArrowArray {
  // Array data description
  int64_t length;
  int64_t null_count;
  int64_t offset;
  int64_t n_buffers;
  int64_t n_children;
  const void** buffers;
  struct ArrowArray** children;
  struct ArrowArray* dictionary;

  // Release callback
  void (*release)(struct ArrowArray*);
  // Opaque producer-specific data
  void* private_data;
};

#endif  // ARROW_C_DATA_INTERFACE

接下来,我们将提供我们将在下面实现的函数的定义

// Builds an ArrowArray of type string that will contain one element for each line
// in src and places it into out.
//
// On success, returns {0, ""}; on error, returns {<errno code>, <error message>}
std::pair<int, std::string> linesplitter_read(const std::string& src,
                                              struct ArrowArray* out);

// Concatenates all elements of a string ArrowArray inserting a newline between
// elements.
//
// On success, returns {0, <result>}; on error, returns {<errno code>, <error message>}
std::pair<int, std::string> linesplitter_write(struct ArrowArray* input);

注意

您可能注意到我们没有在公开给用户的头文件中以任何方式包含或提及 nanoarrow。由于 nanoarrow 被设计为供应商化,并且没有作为系统库分发,因此您的库的用户不能 #include "nanoarrow.h",因为它可能会与另一个库发生冲突(并且可能具有不同版本的 nanoarrow)。

Arrow C 数据/nanoarrow 接口基础#

现在我们已经看到了需要实现的函数以及在 C 数据接口中公开的 Arrow 类型,让我们来解释一些关于使用 Arrow C 数据接口和 nanoarrow 实现中使用的一些约定的基础知识。

首先,让我们讨论 ArrowSchemaArrowArray。您可以将 ArrowSchema 视为数据类型的表达式,而 ArrowArray 是数据本身。这些结构可以容纳嵌套类型:列在每个结构的 children 成员中进行编码。在访问 ArrowArray 的内容之前,您始终需要知道它的数据类型。在我们的例子中,我们只对一种类型(“string”)的数组进行操作,并在我们的接口中记录了这一点;对于对多个类型数组进行操作的函数,您需要接受一个 ArrowSchema 并检查它(例如,使用 nanoarrow 的帮助函数)。

其次,让我们讨论错误处理。您可能在上面的函数定义中注意到我们返回了 int,这是一个与 errno 兼容的错误代码,或返回 0 表示成功。nanoarrow 中需要传递更详细的错误信息的函数会接受一个 ArrowError* 参数(如果调用者不关心额外信息,则可以为 NULL)。任何可能失败的 nanoarrow 函数都会以这种方式传递错误。为了避免像下面这样的冗长代码

int init_string_non_null(struct ArrowSchema* schema) {
  int code = ArrowSchemaInitFromType(&schema, NANOARROW_TYPE_STRING);
  if (code != NANOARROW_OK) {
    return code;
  }

  schema->flags &= ~ARROW_FLAG_NULLABLE;
  return NANOARROW_OK;
}

…您可以使用 NANOARROW_RETURN_NOT_OK()

int init_string_non_null(struct ArrowSchema* schema) {
  NANOARROW_RETURN_NOT_OK(ArrowSchemaInitFromType(&schema, NANOARROW_TYPE_STRING));
  schema->flags &= ~ARROW_FLAG_NULLABLE;
  return NANOARROW_OK;
}

只要您的使用 nanoarrow 的内部函数也返回 int 和/或 ArrowError* 参数,这就可以正常工作。这通常意味着有一个外部函数提供更符合语言习惯的接口(例如,返回 std::optional<> 或抛出异常),以及一个使用 nanoarrow 风格错误处理的内部函数。拥抱 NANOARROW_RETURN_NOT_OK() 是使用 nanoarrow 库时获得幸福的关键。

第三,让我们讨论内存管理。由于 nanoarrow 在 C 中实现,并提供 C 接口,因此该库默认情况下使用 C 风格的内存管理(即,如果您分配了内存,则您需要清理它)。当您可以使用 C++ 时,这将是不必要的,因此 nanoarrow 还提供了一个 C++ 头文件 (nanoarrow.hpp),其中包含围绕需要显式清理的任何内容的 std::unique_ptr<> 类包装器。在 C 中,您可能需要编写这样的代码

struct ArrowSchema schema;
struct ArrowArray array;

// Ok: if this returns, array was not initialized
NANOARROW_RETURN_NOT_OK(ArrowSchemaInitFromType(&schema, NANOARROW_TYPE_STRING));

// Verbose: if this fails, we need to release schema before returning
// or it will leak.
int code = ArrowArrayInitFromSchema(&array, &schema, NULL);
if (code != NANOARROW_OK) {
  ArrowSchemaRelease(&schema);
  return code;
}

…使用 nanoarrow.hpp 类型,我们可以执行以下操作

nanoarrow::UniqueSchema schema;
nanoarrow::UniqueArray array;

NANOARROW_RETURN_NOT_OK(ArrowSchemaInitFromType(schema.get(), NANOARROW_TYPE_STRING));
NANOARROW_RETURN_NOT_OK(ArrowArrayInitFromSchema(array.get(), schema.get(), NULL));

构建库#

我们的库实现将放在 linesplitter.cc 中。在编写实际实现之前,让我们向项目中添加足够的内容,以便我们可以使用 VSCode 的 C/C++/CMake 集成来构建它

#include <cerrno>
#include <cstdint>
#include <sstream>
#include <string>
#include <utility>

#include "nanoarrow/nanoarrow.hpp"

#include "linesplitter.h"

std::pair<int, std::string> linesplitter_read(const std::string& src,
                                              struct ArrowArray* out) {
  return {ENOTSUP, ""};
}

std::pair<int, std::string> linesplitter_write(struct ArrowArray* input) {
  return {ENOTSUP, ""};
}

我们还需要一个 CMakeLists.txt 文件,它告诉 CMake 和 VSCode 要构建什么。CMake 有很多选项,可以扩展到协调非常大的项目;但是,我们只需要几行代码来利用 VSCode 的集成。

project(linesplitter)

set(CMAKE_CXX_STANDARD 11)

include(FetchContent)

FetchContent_Declare(
  nanoarrow
  URL https://github.com/apache/arrow-nanoarrow/releases/download/apache-arrow-nanoarrow-0.2.0/apache-arrow-nanoarrow-0.2.0.tar.gz
  URL_HASH SHA512=38a100ae5c36a33aa330010eb27b051cff98671e9c82fff22b1692bb77ae61bd6dc2a52ac6922c6c8657bd4c79a059ab26e8413de8169eeed3c9b7fdb216c817)
FetchContent_MakeAvailable(nanoarrow)

add_library(linesplitter linesplitter.cc)
target_link_libraries(linesplitter PRIVATE nanoarrow)

保存 CMakeLists.txt 后,您可能需要关闭并在 VSCode 中重新打开 linesplitter 目录以激活 CMake 集成。从命令面板(即,Control/Command-Shift-P)中选择 **CMake:构建**。如果一切顺利,您应该会看到几行输出,指示构建和链接 linesplitter 的进度。

注意

根据您使用的 CMake 版本,您还可能会看到一些警告。这个 CMakeLists.txt 故意保持最简化,因此不会尝试消除这些警告。

注意

如果您没有使用 VSCode,可以在终端中使用 mkdir build && cd build && cmake .. && cmake --build . 来执行等效的任务。

构建 ArrowArray#

我们 linesplitter_read() 函数的输入是 std::string,我们将遍历它并将每个检测到的行作为单独的元素添加。首先,我们将为检测到下一个 \n 或字符串结尾之前的字符数量的核心逻辑定义一个函数。

static int64_t find_newline(const ArrowStringView& src) {
  for (int64_t i = 0; i < src.size_bytes; i++) {
    if (src.data[i] == '\n') {
      return i;
    }
  }

  return src.size_bytes;
}

我们将定义的下一个函数是一个使用 nanoarrow 风格错误处理的内部函数。它使用 nanoarrow 提供的 ArrowArrayAppend*() 函数族来构建数组

static int linesplitter_read_internal(const std::string& src, ArrowArray* out,
                                      ArrowError* error) {
  nanoarrow::UniqueArray tmp;
  NANOARROW_RETURN_NOT_OK(ArrowArrayInitFromType(tmp.get(), NANOARROW_TYPE_STRING));
  NANOARROW_RETURN_NOT_OK(ArrowArrayStartAppending(tmp.get()));

  ArrowStringView src_view = {src.data(), static_cast<int64_t>(src.size())};
  ArrowStringView line_view;
  int64_t next_newline = -1;
  while ((next_newline = find_newline(src_view)) >= 0) {
    line_view = {src_view.data, next_newline};
    NANOARROW_RETURN_NOT_OK(ArrowArrayAppendString(tmp.get(), line_view));
    src_view.data += next_newline + 1;
    src_view.size_bytes -= next_newline + 1;
  }

  NANOARROW_RETURN_NOT_OK(ArrowArrayFinishBuildingDefault(tmp.get(), error));

  ArrowArrayMove(tmp.get(), out);
  return NANOARROW_OK;
}

最后,我们定义一个与外部函数定义相对应的包装器。

std::pair<int, std::string> linesplitter_read(const std::string& src, ArrowArray* out) {
  ArrowError error;
  int code = linesplitter_read_internal(src, out, &error);
  if (code != NANOARROW_OK) {
    return {code, std::string(ArrowErrorMessage(&error))};
  } else {
    return {NANOARROW_OK, ""};
  }
}

读取 ArrowArray#

我们 linesplitter_write() 函数的输入是 ArrowArray*,就像我们在 linesplitter_read() 中创建的那样。就像 nanoarrow 提供帮助程序来构建数组一样,它还通过 ArrowArrayView*() 函数族提供帮助程序来读取它们。同样,我们首先定义一个使用 nanoarrow 风格错误处理的内部函数

static int linesplitter_write_internal(ArrowArray* input, std::stringstream& out,
                                       ArrowError* error) {
  nanoarrow::UniqueArrayView input_view;
  ArrowArrayViewInitFromType(input_view.get(), NANOARROW_TYPE_STRING);
  NANOARROW_RETURN_NOT_OK(ArrowArrayViewSetArray(input_view.get(), input, error));

  ArrowStringView item;
  for (int64_t i = 0; i < input->length; i++) {
    if (ArrowArrayViewIsNull(input_view.get(), i)) {
      out << "\n";
    } else {
      item = ArrowArrayViewGetStringUnsafe(input_view.get(), i);
      out << std::string(item.data, item.size_bytes) << "\n";
    }
  }

  return NANOARROW_OK;
}

然后,提供一个与外部函数定义相对应的外部包装器。

std::pair<int, std::string> linesplitter_write(ArrowArray* input) {
  std::stringstream out;
  ArrowError error;
  int code = linesplitter_write_internal(input, out, &error);
  if (code != NANOARROW_OK) {
    return {code, std::string(ArrowErrorMessage(&error))};
  } else {
    return {NANOARROW_OK, out.str()};
  }
}

测试#

我们有了实现,但是它能正常工作吗?与 R 和 Python 等高级运行时环境不同,我们不能仅仅打开一个提示符并输入一些代码来找出答案。对于 C 和 C++ 库,googletest 框架提供了一种快速简便的方法来做到这一点,并且随着项目的复杂性而扩展。

首先,我们将添加一个存根测试和一些 CMake 来开始。在 linesplitter_test.cc 中,添加以下内容

#include <gtest/gtest.h>

#include "nanoarrow/nanoarrow.hpp"

#include "linesplitter.h"

TEST(Linesplitter, LinesplitterRoundtrip) {
  EXPECT_EQ(4, 4);
}

然后,将以下内容添加到您的 CMakeLists.txt

FetchContent_Declare(
  googletest
  URL https://github.com/google/googletest/archive/refs/tags/v1.13.0.zip
)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(linesplitter_test linesplitter_test.cc)
target_link_libraries(linesplitter_test linesplitter GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(linesplitter_test)

完成之后,再次使用命令面板中的 **CMake:构建** 命令构建项目。如果一切顺利,选择 **CMake:刷新测试**,然后选择 **测试:运行所有测试** 命令来运行它们!您应该会看到一些输出,指示测试已成功运行,或者您可以使用 VSCode 的“测试”面板来直观地检查哪些测试已通过。

注意

如果您没有使用 VSCode,可以在终端中使用 cd build && ctest . 来执行等效的任务。

现在我们准备填写测试!我们的两个函数正好是往返的,因此第一个有用的测试可能是检查这一点。

TEST(Linesplitter, LinesplitterRoundtrip) {
  nanoarrow::UniqueArray out;
  auto result = linesplitter_read("line1\nline2\nline3", out.get());
  ASSERT_EQ(result.first, 0);
  ASSERT_EQ(result.second, "");

  ASSERT_EQ(out->length, 3);

  nanoarrow::UniqueArrayView out_view;
  ArrowArrayViewInitFromType(out_view.get(), NANOARROW_TYPE_STRING);
  ASSERT_EQ(ArrowArrayViewSetArray(out_view.get(), out.get(), nullptr), 0);
  ArrowStringView item;

  item = ArrowArrayViewGetStringUnsafe(out_view.get(), 0);
  ASSERT_EQ(std::string(item.data, item.size_bytes), "line1");

  item = ArrowArrayViewGetStringUnsafe(out_view.get(), 1);
  ASSERT_EQ(std::string(item.data, item.size_bytes), "line2");

  item = ArrowArrayViewGetStringUnsafe(out_view.get(), 2);
  ASSERT_EQ(std::string(item.data, item.size_bytes), "line3");


  auto result2 = linesplitter_write(out.get());
  ASSERT_EQ(result2.first, 0);
  ASSERT_EQ(result2.second, "line1\nline2\nline3\n");
}

以这种方式编写测试还可以打开一个相对简单的调试路径,可以使用 **CMake:设置调试目标** 和 **CMake:调试** 命令。如果您编写并运行测试时首先发生的是崩溃,则在打开调试器的情况下运行测试会自动在导致崩溃的代码行暂停。为了更精细的调试,您可以设置断点并逐步执行代码。

总结#

本教程介绍了编写和测试一个 C++ 库的基础知识,该库公开了使用 nanoarrow C 库实现的基于 Arrow 的 API。