Apache Arrow C++ 数组体系全解析:从 ArrayData、Array 到 ChunkedArray 与 ArrayVisitor
【免费下载链接】arrowApache Arrow is a multi-language toolbox for accelerated data interchange and in-memory processing项目地址: https://gitcode.com/gh_mirrors/arrow13/arrow
Apache Arrow 在cpp/src/arrow/array/下构建了一套完整、统一的 C++ 数组(Array)体系,docs/source/cpp/api/array.rst正是这一体系的官方 API 索引:它从最底层的ArrayData开始,依次覆盖抽象基类Array、工厂函数、Primitive / Temporal / Binary-like / Nested / Dictionary / Extension 等具体数组子类,以及逻辑上把多个 Array 聚合成一个大数组的ChunkedArray,最后以类型分发的ArrayVisitor收尾。本文以该文档为主线,结合仓库源码逐层剖析这套体系的类层次、内存布局、访问器语义与常用工具,读完你将对 Arrow C++ 中"一个数组到底长什么样、如何构造、如何读写、如何切分与验证"有完整认识,并能直接对照源码继续深挖任意一个具体数组类型。
ArrayData:数组内存的通用容器
arrow::ArrayData是整棵数组类树的"地基",array.rst将它与Array并列放在文档最前面。它在 cpp/src/arrow/array/data.h 中定义,注释里明确它的定位:一个自包含的、描述 Arrow 数组内存与元数据的容器(相当于 Java 实现中的 Vector),而arrow::Array及其子类则提供强类型访问器,并支持访问者模式(visitor pattern)。
核心成员与构造
ArrayData的核心字段包括:
| 字段 | 类型 | 含义 |
|---|---|---|
type | std::shared_ptr<DataType> | 数组的逻辑类型,如int64、utf8、list |
length | int64_t | 数组元素个数 |
null_count | int64_t(原子) | 物理空值个数,未知时用kUnknownNullCount = -1表示,首次访问时惰性计算 |
offset | int64_t | 相对父数组数据的偏移,用于零拷贝切片,默认为 0 |
buffers | std::vector<std::shared_ptr<Buffer>> | 数据缓冲区列表,buffers[0]通常是有效性位图(validity bitmap) |
child_data | std::vector<std::shared_ptr<ArrayData>> | 嵌套类型的子数组数据(如 List 的值数组、Struct 的字段数组) |
dictionary | std::shared_ptr<ArrayData> | 字典数组专用的字典数据 |
null_count = -1这一设计来自 ARROW-33(见 data.h 注释):切片时并不知道切片范围的空值个数,为避免急切计算,先置为负数,当Array::null_count()第一次被调用时才计算并缓存。ArrayData还提供多个Make静态工厂(data.h#L126-L146),支持仅传类型与长度、带 buffers、带 child_data、带 dictionary 四种形态。
缓冲区访问
ArrayData提供了一组模板化访问器:GetValues<T>(i, absolute_offset)把第 i 个 buffer 按类型 T 解释为指针;GetValuesSafe<T>在缓冲区非 CPU buffer 时返回NULLPTR而不是崩溃,GetMutableValues<T>则拿到可写指针。Slice(offset, length)构造零拷贝切片(不复制底层 buffer,只调整 offset/length),SliceSafe是带边界检查的版本。
空值判定:物理与逻辑
理解 Arrow 的空值模型要从ArrayData的几个方法入手:
HasValidityBitmap():buffers[0] != NULLPTR,即是否存在有效性位图。MayHaveNulls():有效性位图存在且null_count != 0。MayHaveLogicalNulls()与ComputeLogicalNullCount():除了位图,还会检查 union、run-end encoded、dictionary 这类没有位图但子数据可能含空的类型,见 data.h 中对SPARSE_UNION/DENSE_UNION/RUN_END_ENCODED/DICTIONARY的分派。
data.h#L321-L343还给出了一个迁移示例:老代码用array.MayHaveNulls()判断是否需要检查位图,新代码应改用MayHaveLogicalNulls()+HasValidityBitmap()的组合,才能正确处理 union 等特殊类型。
一次典型的数据复用
ArrayData注释(data.h#L71-L77)演示了最经典的用法——改类型不改内存:
Int64Array arr = GetMyData(); auto new_data = arr.data()->Copy(); new_data->type = arrow::float64(); DoubleArray double_arr(new_data);由于 Int64 与 Float64 物理布局相同(同为 8 字节定宽),直接把ArrayData拷贝一份、替换type字段就能"零拷贝"得到一个新类型的数组。这也解释了为什么ArrayData被设计为可变的(mutable),而对外暴露的Array是不可变的(immutable)。
Array:不可变数组的抽象基类
arrow::Array(array_base.h)是所有用户可见数组类型的基类。它的设计哲学是:不可变、零拷贝、内存由 Buffer 持有。基类只要求:若空值个数大于 0,则需要有效性位图 buffer;若空值个数未知,构造时可传 -1 让它惰性计算。
常用访问器
| 方法 | 说明 |
|---|---|
length() | 元素个数(来自data_->length) |
offset() | 相对数据起点偏移(零拷贝切片核心) |
null_count() | 物理空值个数,首次调用时计算并缓存 |
ComputeLogicalNullCount() | 对无位图类型(union、run-end encoded)也给出正确的逻辑空值数 |
IsNull(i)/IsValid(i) | 按索引判空/判有效,不做边界检查,返回bool |
type()/type_id() | 逻辑类型对象及其枚举 id |
null_bitmap()/null_bitmap_data() | 有效性位图 buffer 及其裸指针 |
GetScalar(i) | 把第 i 个元素包装为Scalar对象 |
IsValid的实现值得注意(array_base.h#L62-L80):如果有效性位图存在,直接用位运算bit_util::GetBit(null_bitmap_data_, i + data_->offset)取值;否则按类型分派——SPARSE_UNION、DENSE_UNION、RUN_END_ENCODED 调用internal命名空间下的专用函数,其它类型则退化为null_count != length。注释特别强调:把IsNull做成内联、非虚函数是为了避免每次调用都走 vtable 查表,这正是 Arrow 注重列式处理性能的体现。
切片、比较与校验
- 切片:
Slice(offset, length)是零拷贝的——它返回共享同一批 buffer 的新Array对象,只更新offset与length;SliceSafe增加输入检查。需要复制到其它设备时,可用CopyTo(MemoryManager)(递归拷贝全部 buffer 与子数组)或ViewOrCopyTo(优先尝试零拷贝视图,失败再回退复制)。 - 比较:
Equals(严格相等)、ApproxEquals(近似相等,epsilon仅对 Float/Double 生效)、RangeEquals(指定区间比较);Diff(other)返回格式化的统一 diff 文本(底层是 cpp/src/arrow/array/diff.cc 的arrow::Diff)。 - 校验:
Validate()做轻量检查,时间复杂度 O(k)(k 为子孙节点数);ValidateFull()做深度检查,最坏 O(k·n)(n 为数组长度),适合调试阶段使用。 - 视图:
View(type)在类型布局兼容的前提下零拷贝改变解释类型;ToString()输出适合调试的 PrettyPrint 表示。 - 设备:
device_type()返回底层数据所在设备(CPU/GPU),它委托给ArrayData::device_type()。
层次划分:FlatArray 与 PrimitiveArray
array_base.h在Array之下还有两个中间基类:
FlatArray:非嵌套数组的基类(标记类,无新增逻辑)。PrimitiveArray:定宽逻辑类型数组的基类,持有raw_values_指针指向data_->buffers[1](索引 1 即数据缓冲区,索引 0 是有效性位图),并暴露values()方法。
NullArray:退化类型的特例
NullArray(array_base.h)是 null 类型数组,长度任意但每个元素都是 null。它的SetData直接置null_bitmap_data_ = NULLPTR并把null_count强制设为length——不需要任何实际 buffer。
工厂函数:从 ArrayData 到 Array
array.rst的 "Factory functions" 一节引用了 Doxygen 组array-factories,对应头文件 cpp/src/arrow/array/util.h 中的一组顶层函数:
| 函数 | 作用 |
|---|---|
MakeArray(data) | 根据ArrayData的type自动构造对应具体类型的Array(最常用的入口) |
MakeArrayOfNull(type, length, pool) | 构造指定类型、指定长度的全 null 数组 |
MakeArrayFromScalar(scalar, length, pool) | 用标量值填充指定长度的数组 |
其中MakeArrayOfNull与MakeArrayFromScalar返回Result<std::shared_ptr<Array>>(util.h#L49-L58),需要处理错误状态。实际生产代码中,从 IPC 读取或从 Builder 构建出ArrayData之后,通常就通过MakeArray拿到类型安全的Array对象。这些工厂函数底层依赖各具体类型构造函数按类型分派,可在 cpp/src/arrow/array/array_base.cc 与 util.cc 中查看实现。
具体数组子类
array.rst把具体子类分成五组,下面分别对应到源码文件。
Primitive 与 Temporal(原始与时间类型)
这一组包括NullArray、BooleanArray和numeric-arrays组。
BooleanArray(array_primitive.h):布尔值按位紧凑存储(1 元素占 1 bit),Value(i)用bit_util::GetBit读取,并提供false_count()/true_count()(不缓存、每次重算)以及begin()/end()迭代器。operator[](i)返回std::optional<bool>(null 槽位返回std::nullopt)。
NumericArray<TYPE>(array_primitive.h#L86-L120):模板类,按对应DataType子类实例化,例如NumericArray<Int8Type>、NumericArray<Date32Type>;value_type取TypeClass::c_type(如int64_t、double)。raw_values()返回已叠加 slice offset 的裸指针;Value(i)直接下标读取;operator[](i)返回std::optional<value_type>。文档引用的numeric-arrays组覆盖:整型(Int8/16/32/64、UInt8/16/32/64)、浮点(HalfFloat、Float、Double)、Decimal128/256,以及时间类型(Date32/64、Time32/64、Timestamp、Duration、Month/DayTime/MonthDayNano Interval)。它们都有便捷别名,如Int32Array即NumericArray<Int32Type>。
Binary-like(二进制类)
binary-arrays组(array_binary.h)包括:
BinaryArray/StringArray:定偏移(int32)的变长二进制/字符串,value_offset(i)与value_length(i)决定第 i 个值的位置与长度;LargeBinaryArray/LargeStringArray:int64 偏移版本,支持超过 2 GiB 的单数组;FixedSizeBinaryArray:定宽二进制;BinaryViewArray/StringViewArray:view 类型,把短值内联、长值用 view 结构引用,减少拷贝;Decimal128Array/Decimal256Array:在 array_decimal.h 中定义。
注意 2 GiB 上限与普通 BinaryArray 偏移用 int32 有关——这正是 Large 系列存在的意义。
Nested(嵌套类型)
nested-arrays组(array_nested.h)包含:
ListArray/LargeListArray:基于value_offsets缓冲区和子values数组;FromArrays(offsets, values, ...)工厂负责最基础的偏移校验(array_nested.h#L172-L195),若 offsets 含 null 会自动分配新 offsets 数组。ListViewArray/LargeListViewArray:list 的 view 变体,偏移表示的是跨度而非前缀和。FixedSizeListArray:每个槽位固定包含 n 个值,无需偏移数组。MapArray:键值对列表的语义封装。StructArray:多个子数组按位置对齐,num_fields()即字段数。SparseUnionArray/DenseUnionArray:联合类型,不同槽位可持有不同类型。RunEndEncodedArray(array_run_end.h):游程编码数组。
所有 List 变体共享模板基类VarLengthListLikeArray(array_nested.h#L78-L137),统一提供values()、value_offsets()、value_offset(i)、value_length(i)、value_slice(i)(取第 i 个列表元素为一个子数组)等接口,并支持FlattenRecursively()把任意深度的列表展平成非列表数组。StructArray 的Flatten()则返回每个字段独立的数组。
Dictionary-encoded(字典编码)
DictionaryArray(array_dict.h)由一个非负整数索引数组加一个字典数组组成。例如["foo", "bar", "foo", "bar", "foo", "bar"]配字典["bar", "foo"],索引就是[1, 0, 1, 0, 1, 0](文档注释原例)。核心 API:
indices()/dictionary():分别取索引数组与字典;GetValueIndex(i):把第 i 个索引转为int64_t(非性能敏感场景使用);FromArrays(type, indices, dictionary):构造并校验所有索引非负且小于字典大小;Transpose(type, dictionary, transpose_map, pool):配合DictionaryUnifier做字典统一后的索引转置;Compact(pool):压缩字典。
DictionaryUnifier(array_dict.h#L126-L180)是配套工具类:Unify逐字典追加并产出转置索引,UnifyChunkedArray/UnifyTable可跨 chunk、跨表列统一字典,最终GetResult返回最小可容纳的索引类型。它只直接支持原始值类型的字典,但嵌套在复杂类型内的字典会被正确统一。
Extension arrays(扩展数组)
ExtensionArray(extension_type.h)是用户自定义类型的载体:它包裹一个"存储数组"(storage array),storage()方法返回底层数组,用户类型通过继承ExtensionType定义。扩展数组在逻辑上表现为用户类型,但物理存储复用现有 Arrow 布局,是 Arrow 类型系统可扩展性的关键。
ChunkedArray:逻辑上的大数组
单个Array要求内存连续,而现实中的数据(尤其是变长二进制、字符串)往往无法或不宜一次性分配为单个大数组。ChunkedArray(cpp/src/arrow/chunked_array.h)正是为此而生:它把一组同类型的Array(chunk)聚合成一个逻辑上的大数组,不需要昂贵的拼接(concatenation)步骤。其头文件注释还给出了两条重要约定:
- 处理函数的结果 chunk 布局不保证与输入一致——API 不把"保持 chunk 布局"作为契约;
- 当函数输出可能超过单个
Array容量(如超大BinaryArray/StringArray)时,官方推荐直接返回ChunkedArray而不是std::vector<Array>。
构造与访问
ChunkedArray(chunk):单 chunk 构造;ChunkedArray(chunks, type):从ArrayVector构造,所有 chunk 必须同类型;显式传type时允许空向量;Make(chunks, type):带输入校验的构造;MakeEmpty(type, pool):创建指定类型的空 ChunkedArray(含一个空数组 chunk)。
访问接口:length()(各 chunk 长度之和,构造时算好)、null_count()(全 chunk 空值总数)、num_chunks()、chunk(i)/chunks()、Slice(offset, length)(零拷贝,跨 chunk 边界也能切)、Flatten(pool)(Struct 类型展开为每个字段一个 ChunkedArray)、View(type)(对每个 chunk 调用Array::View)、GetScalar(index)。此外还有Equals、ToString等常规能力,以及用于拼接 chunk 的Concatenate(concatenate.h)。
从源码结构看,ChunkedArray 与 RecordBatch / Table 共同构成了 Arrow 的"逻辑数据集"层:Table 的一列就是一个 ChunkedArray,而列式处理引擎(compute 模块、dataset/scanner)几乎都以 ChunkedArray 为输入输出单位。
ArrayVisitor:类型分发的访问者模式
array.rst最后的 Utilities 一节引用了arrow::ArrayVisitor(cpp/src/arrow/visitor.h)。它为一每一类具体数组声明了一个虚函数Visit(const XxxArray&),覆盖从NullArray、BooleanArray、全部整型/浮点/字符串/二进制/时间/Decimal 数组,到 List、Struct、Map、Union、RunEndEncoded、Dictionary、Extension 等所有类型(visitor.h#L34-L69)。
用法分两步:先继承ArrayVisitor并重写关心的Visit重载,再调用array->Accept(visitor)。Array::Accept(array_base.h)会根据数组的实际类型把调用分派到对应的Visit重载,从而避免手写一长串if (type == Type::INT32) ... else if ...的类型分派代码。默认的Visit实现返回Status::NotImplemented,因此只重写需要处理的类型即可。这是 Arrow 中编写"对任意数组类型通用"的遍历、序列化、比较逻辑的标准手法。
小结:一套体系,五种层次
回到array.rst的组织结构,可以把 Arrow C++ 数组体系归纳为五层:
- ArrayData:底层可变内存容器,描述 type/length/null_count/offset/buffers/children/dictionary;
- Array 与中间基类:不可变访问层,
FlatArray→PrimitiveArray划分了定宽与非嵌套逻辑; - 工厂函数:
MakeArray等从ArrayData/ 标量一键构建具体数组; - 具体子类:Primitive/Temporal、Binary-like、Nested、Dictionary、Extension 五大家族(对应 cpp/src/arrow/array/ 下的
array_primitive.h、array_binary.h、array_nested.h、array_dict.h,以及extension_type.h); - 组合与工具:
ChunkedArray聚合多个 Array 为逻辑大数组,ArrayVisitor提供类型安全的分发遍历。
要深入验证本文中的每个 API 行为,可以在 cpp/src/arrow/array/array_test.cc、array_binary_test.cc、array_dict_test.cc、array_list_test.cc、concatenate_test.cc 等测试文件中找到对应的单元测试用例;进一步了解数据写入方向(Builder 体系)可继续阅读 cpp/src/arrow/array/builder_base.h 及其同目录下的 builder 系列头文件。
【免费下载链接】arrowApache Arrow is a multi-language toolbox for accelerated data interchange and in-memory processing项目地址: https://gitcode.com/gh_mirrors/arrow13/arrow
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考