ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Julia 自定义索引数组(Offset Arrays)完全指南:从 `axes` 到自定义 `AbstractArray`

Julia 自定义索引数组(Offset Arrays)完全指南:从 `axes` 到自定义 `AbstractArray` Julia 自定义索引数组Offset Arrays完全指南从axes到自定义AbstractArray【免费下载链接】juliaThe Julia Programming Language项目地址: https://gitcode.com/gh_mirrors/ju/julia导读Julia 默认使用 1 起始索引但许多算法尤其是傅里叶变换、图像卷积、有限差分等数值计算在允许索引越出1:size(A, d)范围时会大幅简化实现。为此Julia 原生支持自定义索引数组arbitrary/offset indexing arrays。本篇指南以官方开发文档doc/src/devdocs/offset-arrays.md为主体结合base/abstractarray.jl、base/indices.jl、base/range.jl等源码实现系统讲解如何让既有代码兼容非常规索引数组、如何从零编写一个非 1 起始索引的自定义数组类型以及如何用require_one_based_indexing、axes、LinearIndices、similar等接口安全地处理这类数组。1 背景为什么 Julia 需要自定义索引数组多数语言在数组索引上遵循单一约定Julia 从 1 开始C/C 从 0 开始Fortran 允许任意起始下标。Julia 选择 1 起始索引作为标准但标准之外存在一类真实需求——有些算法在索引范围扩展到1:size(A,d)之外时会显著简化例如需要访问哨兵位置或利用对称边界条件而不仅是常见的0:size(A,d)-1。为了支持这类计算Julia 在语言层面设计了自定义索引数组机制。从源码结构看这套机制的核心支点有三个axes返回每个维度合法的索引范围AbstractUnitRange元组定义于 base/abstractarray.jl#L79-L82Base.OneTo类型系统层面保证下界为 1 的索引范围定义于 base/range.jl#L485-L504similar的shape重载按索引形状而非仅尺寸分配存储。2 先回答最实际的问题我的代码需要改吗如果你确定代码永远不会遇到非常规索引的数组那么答案很可能是**什么都不用做**。只要代码使用了 Julia 导出的标准接口旧代码在传统数组上基本可以原样运行。文档原文的承诺是老代码无需改动即可继续工作因为非常规索引的破坏力只发生在你或你调用的库隐含假设所有数组都从 1 开始的时候。如果你希望更稳妥——直接拒绝非常规索引数组可以在函数入口加一行Base.require_one_based_indexing(arrays...)其中arrays...是待检查的数组对象列表。其实现位于 base/abstractarray.jl#L135require_one_based_indexing(A...) !has_offset_axes(A...) || throw(ArgumentError(offset arrays are not supported but got an array with index other than 1))即一旦检测到任何传入数组存在偏移索引立即抛出ArgumentError而不是让后续代码在错误假设下静默产出错误结果甚至崩溃。底层检测函数has_offset_axes同样位于 base/abstractarray.jl#L114-L122其核心逻辑是检查axes(A)中任一维度的首索引是否为 1并对Array、Colon等类型做了快速路径优化。Base 内部大量使用该防线例如 base/abstractarraymath.jl#L530、base/array.jl#L1087、base/combinatorics.jl#L127 等均通过require_one_based_indexing明确声明此算法不支持偏移索引。3 泛化既有代码的三步走如果要让既有代码支持任意索引数组核心步骤归纳为三条原文档给出的总纲将大量size用法替换为axes将1:length(A)替换为eachindex(A)某些场景用LinearIndices(A)将显式分配如Array{Int}(undef, size(B))替换为similar(Array{Int}, axes(B))。3.1 为什么旧代码会崩溃一个典型反例非常规索引会打破所有数组都从 1 开始的假设最令人头疼的后果是错误结果或段错误Julia 整体崩溃。原文档给出如下警示示例function mycopy!(dest::AbstractVector, src::AbstractVector) length(dest) length(src) || throw(DimensionMismatch(vectors must match)) # OK, now were safe to use inbounds, right? (not anymore!) for i 1:length(src) inbounds dest[i] src[i] end dest end这段代码隐含假设向量从 1 开始索引inbounds跳过了边界检查而循环1:length(src)假设src的合法下标恰好是1:length(src)。若dest与src起始索引不同dest[i]可能访问非法内存——这正是长度相等所以安全直觉失效的地方。若真的遇到段错误可用julia --check-boundsyes重启定位问题见第 6 节。3.2 用axes做边界检查与循环迭代axes(A)写法上让人联想到size(A)返回一个AbstractUnitRange{:Integer}元组给出A每个维度合法的索引范围当A索引非常规时这些范围不一定从 1 开始。只需要某一维时用axes(A, d)。关键实现细节base/abstractarray.jl#L79-L82function axes(A::AbstractArray{T,N}, d) where {T,N} inline d::Integer N ? axes(A)[d] : OneTo(1) end注意当d ndims(A)时默认返回OneTo(1)即超出维度的轴长为 1这一约定。Base 在 base/abstractarray.jl#L140-L142 还提供了内部性能优化函数axes1axes1(A::AbstractArray{:Any,0}) OneTo(1) axes1(A::AbstractArray) (inline; axes(A)[1])它等价于axes(A, 1)但省去了运行期ndims(A) 0的分支判断零维数组直接返回OneTo(1)纯粹为性能而存在。Base 还专门实现了自定义范围类型OneTobase/range.jl#L485-L504OneTo(n)语义与1:n相同但通过类型系统保证下界为 1——任何新的AbstractArray类型默认都应从axes返回它以此声明本类型使用传统 1 起始索引。OneTo构造器还会校验输入拒绝Bool类型索引拒绝首元素非 1 或步长非 1 的 range。做边界检查时还应留意两个专用函数checkbounds与checkindexbase/abstractarray.jl#L682-L762它们常能让这类判断更简洁、更统一。文档注释中还给出一个实战技巧对axes(A, d)取子区间时应使用begin/end或firstindex/lastindex而非字面量下标例如ix[(begin1):end]这样才能在泛化索引上正确工作base/abstractarray.jl#L67-L77。3.3 线性索引LinearIndices某些算法用单一线性下标A[i]书写最方便即使A是多维数组。约定无论数组原生索引如何线性索引的范围始终是1:length(A)。但这给一维数组AbstractVector带来歧义v[i]究竟表示线性索引还是按数组原生索引的笛卡尔索引因此你的最佳选择通常是eachindex(A)迭代数组若确实需要连续的整数索引序列则调用LinearIndices(A)——它在A是AbstractVector时返回axes(A, 1)否则返回1:length(A)的等价物base/indices.jl#L476-L533其底层结构体LinearIndices{N,R} : AbstractArray{Int,N}在 base/indices.jl#L522-L524。由此定义一维数组始终使用数组原生索引的笛卡尔索引。为强化该约定索引转换函数会在形状表明一维数组 非常规索引即轴为Tuple{UnitRange}而非OneTo元组时抛出错误常规索引数组则一如既往正常工作。eachindex的实现也印证了这一点base/abstractarray.jl#L322-L396eachindex(A::AbstractVector)直接返回axes1(A)即向量迭代使用的是其原生索引而非1:lengthIndexLinear数组则走oneto(length(A))的快速路径。用axesLinearIndices重写mycopy!的正确版本function mycopy!(dest::AbstractVector, src::AbstractVector) axes(dest) axes(src) || throw(DimensionMismatch(vectors must match)) for i in LinearIndices(src) inbounds dest[i] src[i] end dest end这里用axes(dest) axes(src)替换原来的长度相等检查——长度相等不再保证两向量索引对齐而索引范围相等才是dest[i] src[i]真正安全的前提迭代改用LinearIndices(src)后i在两个向量上都是合法下标。3.4 用泛化的similar分配存储存储分配常写成Array{Int}(undef, dims)或similar(A, args...)。当结果需要与某个数组的索引对齐时这两种写法都不够。通用替代模式是similar(storagetype, shape)storagetype表示你想要的底层常规行为例如Array{Int}、BitArray甚至dims-zeros(Float32, dims)分配全零数组shape是Integer或AbstractUnitRange的元组指定结果要使用的索引。便捷技巧zeros(A)可直接产出一个与A索引对齐的全零数组。两个显式示例若A索引常规similar(Array{Int}, axes(A))最终调用Array{Int}(undef, size(A))返回普通数组若A是非常规索引的AbstractArraysimilar(Array{Int}, axes(A))应返回行为像Array{Int}、但形状含索引与A一致的对象——最直接的实现是分配Array{Int}(undef, size(A))后包装一层偏移索引类型。另一例similar(Array{Int}, (axes(A, 2),))会分配一个与A各列索引对齐的AbstractVector{Int}一维数组。Base 内部对按轴分配的处理可参见 base/abstractarray.jl#L829-L845similar(a, T, dims)通过to_shape把AbstractOneTo规约回整数尺寸而普通AbstractUnitRange原样保留——这一区分正是非常规索引数组能按轴分配的机制基础。4 编写非 1 起始索引的自定义数组类型多数需要定义的方法与任何AbstractArray类型一致参见 Abstract Arrays 接口文档 相关章节。本节聚焦非常规索引特有的步骤。4.1 自定义AbstractUnitRange类型编写非 1 起始索引数组时应特化axes使其返回UnitRange或更好返回自定义AbstractUnitRange。自定义类型的优势在于向similar等函数信号化分配类型例如要写一个 0 起始索引的数组先创建新的AbstractUnitRange——ZeroRange其中ZeroRange(n)等价于0:n-1。一个反直觉但重要的设计建议通常不要把ZeroRange从你的包中导出。允许不同包各自实现自己的ZeroRange反而是一种优势ModuleA.ZeroRange指示similar创建ModuleA.ZeroArrayModuleB.ZeroRange指示创建ModuleB.ZeroArray。这种类型隔离让众多自定义数组类型和平共存互不干扰。若不想手写可以借助社区包CustomUnitRanges.jl见原文档引用的 CustomUnitRanges.jl减少样板代码——注意文章此处仅转述原文档信息该包位于仓库之外使用前请自行查阅其文档。4.2 特化axes有了AbstractUnitRange类型后特化axesBase.axes(A::ZeroArray) map(n-ZeroRange(n), A.size)这里假设ZeroArray含一个名为size的字段实现方式可以有多种。有时默认的axes(A, d)回退定义base/abstractarray.jl#L79-L82axes(A::AbstractArray{T,N}, d) where {T,N} d N ? axes(A)[d] : OneTo(1)不符合你的需求——尤其当d ndims(A)时你可能希望返回OneTo(1)以外的值那就需要特化它。同理若零维情形base/abstractarray.jl#L140 的axes1(A::AbstractArray{:Any,0}) OneTo(1)对你的类型有问题务必相应特化axes1。4.3 特化similar有了自定义ZeroRange还应补充两个similar特化方法function Base.similar(A::AbstractArray, T::Type, shape::Tuple{ZeroRange,Vararg{ZeroRange}}) # body end function Base.similar(f::Union{Function,DataType}, shape::Tuple{ZeroRange,Vararg{ZeroRange}}) # body end两个方法都应分配你的自定义数组类型——第一个服务于按参考数组的轴分配第二个服务于按纯形状分配。Base 中similar(::Type{T}, dims::DimOrInd...)系列的规约逻辑见 base/abstractarray.jl#L869-L873。4.4 特化reshape可选可选地定义Base.reshape(A::AbstractArray, shape::Tuple{ZeroRange,Vararg{ZeroRange}}) ...即可把数组reshape成带自定义索引的结果。4.5 面向模拟 AbstractArray 但非其子类型的对象has_offset_axes依赖对象上有可用的axes方法。若你的对象因某种原因无法定义axes可以考虑直接定义Base.has_offset_axes(obj::MyNon1IndexedArraylikeObject) true这样假设 1 起始索引的代码就能检测到问题并抛出友好错误而不是返回错误结果或让 Julia 段错误。4.6 捕获错误如果新数组类型在其他代码中触发错误有两个调试手段注释掉getindex/setindex!实现中的boundscheck强制每次元素访问都做边界检查用julia --check-boundsyes重启全局开启边界检查。某些情况下临时禁用新数组类型的size与length也有帮助——因为做出错误假设的代码常常依赖这两个函数。5 测试与生态中的落地证据仓库测试对偏移索引的支持相当完备。test/abstractarray.jl#L1444-L1445与test/arrayops.jl#L4-L5均引入了测试辅助包testhelpers/OffsetArrays.jl后者提供了可复现的偏移数组实现用于验证 Base 各泛化接口的行为例如test/arrayops.jl#L1637oa OffsetArray(Vector(1:10), -5)构造负起始索引向量验证相关操作test/arrayops.jl#L1923-L1924append!/prepend!与偏移数组的互操作test/abstractarray.jl#L2043-L2047专门验证has_offset_axes的编译期优化IR 完全消除印证了该函数在热路径上的性能设计。这些测试既是对本页文档所述接口的行为约束也是你编写自定义索引数组时可对照的行为契约。6 运行与调试命令速查命令/API作用Base.require_one_based_indexing(A...)断言所有传入数组均为 1 起始索引否则抛ArgumentErroraxes(A)/axes(A, d)返回全部/第d维合法索引范围AbstractUnitRange元组eachindex(A)返回适合迭代A的索引向量为原生索引多维线性数组为1:lengthLinearIndices(A)向量返回axes(A,1)其余返回1:length(A)等价物similar(storagetype, shape)按索引形状而非仅尺寸分配存储julia --check-boundsyes全局强制边界检查用于定位越界段错误7 结语支持自定义索引数组的核心心智模型可以概括为一句话永远不要假设索引从 1 开始除非你用类型系统证明了它OneTo就是这样的证明。具体到工程实践就是三件事——用axes描述索引空间、用eachindex/LinearIndices安全迭代、用similar的轴形状语义分配存储而当你的算法确实无法泛化时require_one_based_indexing是廉价而明确的护栏。按照本文步骤你既可以让旧代码平稳兼容非常规索引数组也能从零构建出与 Base 生态无缝协作的自定义索引数组类型。【免费下载链接】juliaThe Julia Programming Language项目地址: https://gitcode.com/gh_mirrors/ju/julia创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表