在 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++ 库。在本教程中,我们将使用 VSCode 和 CMake。您需要安装这两个工具才能跟上教程:VSCode 可以从官方网站下载,适用于大多数平台;CMake 通常通过您喜欢的包管理器安装(例如 brew install cmake、apt-get install cmake、dnf install cmake 等)。您还需要一个 C 和 C++ 编译器:在 MacOS 上,可以使用 xcode-select --install 安装;在 Linux 上,您需要提供 gcc、g++ 和 make 的软件包(例如 apt-get install build-essential);在 Windows 上,您需要从官方下载页面安装 Visual Studio 和 CMake。
安装 VSCode 后,请确保您已安装 CMake Tools 和 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 被设计为可内置(vendored)且不作为系统库分发,所以您的库用户 #include "nanoarrow.h" 是不安全的,因为它可能与另一个同样操作的库(可能使用不同版本的 nanoarrow)发生冲突。
Arrow C 数据/nanoarrow 接口基础#
现在我们已经看到了需要实现的函数以及 C 数据接口中公开的 Arrow 类型,让我们来解析一些关于使用 Arrow C 数据接口的基础知识以及 nanoarrow 实现中使用的一些约定。
首先,我们来讨论 ArrowSchema 和 ArrowArray。您可以将 ArrowSchema 看作是数据类型的表达,而 ArrowArray 则是数据本身。这些结构支持嵌套类型:列被编码在每个结构的 children 成员中。在访问 ArrowArray 的内容之前,您总是需要知道它的数据类型。在我们的例子中,我们只操作一种类型的数组(“字符串”),并在我们的接口中注明了这一点;对于操作多种类型数组的函数,您需要接受一个 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_static)
保存 CMakeLists.txt 后,您可能需要关闭并重新打开 VSCode 中的 linesplitter 目录以激活 CMake 集成。从命令面板(即 Control/Command-Shift-P)中,选择 CMake: Build。如果一切顺利,您应该会看到几行输出,表明构建和链接 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: Build 命令构建项目。如果一切顺利,从命令面板中选择 CMake: Refresh Tests,然后选择 Test: Run All Tests 来运行它们!您应该看到一些输出,表明测试成功运行,或者您可以使用 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: Set Debug target 和 CMake: Debug 命令。如果运行测试时遇到的第一件事是崩溃,使用调试器运行测试将自动在导致崩溃的代码行暂停。对于更精细的调试,您可以设置断点并单步执行代码。
总结#
本教程涵盖了编写和测试一个 C++ 库的基础知识,该库公开一个基于 Arrow 的 API,并使用 nanoarrow C 库实现。