第22课|访问类型的高级特性

7 阅读13分钟

本课目标

完成本课后,你将能够:

  • 理解访问类型(指针)与动态分派的结合,掌握 access T'Class 的用途
  • 区分通用访问类型(access all)与匿名访问(access 参数)的适用场景
  • 掌握访问类型在容器(链表、动态数组)和回调(函数指针)中的应用
  • 理解多种内存管理策略:手动释放、存储池、引用计数,并分析其优劣
  • 运用访问类型的安全性限制(aliasednot null 等)编写可靠代码

一、回顾与引入

在第14课中,我们学习了访问类型(指针)的基础:使用 new 分配对象、通过 .all 解引用、手动释放内存。然而,在实际工程中,访问类型常常与面向对象(标记记录)和泛型结合,用于构建灵活的容器、回调机制以及复杂数据结构。本课将在第14课的基础上,深入探讨访问类型的高级特性,尤其是如何安全高效地管理动态内存,并与多态协同工作。

Ada 的访问类型设计原则是“在保证安全的前提下提供必要的灵活性”。与 C 语言不同,Ada 不允许任意地址转换,也不允许取局部变量的地址(除非显式标记 aliased)。这些限制避免了大量常见指针错误,但同时也要求程序员更清晰地表达意图。本课将展示如何在限制内实现强大的功能。


二、访问类型与动态分派的结合

2.1 access T'Class:多态指针

在第21课中,我们学习了类宽类型 T'Class 可以实现动态分派,但无法直接声明 T'Class 的变量。要持有不同派生类型的对象,通常使用访问类型指向 T'Class。例如:

type Shape_Ptr is access all Shape'Class;

这种指针可以指向任何 Shape 的派生对象(如 CircleRectangle),并通过该指针调用 primitive operations,实现多态行为。

S1 : Shape_Ptr := new Circle'(Center => (0.0,0.0), Radius => 5.0);
S2 : Shape_Ptr := new Rectangle'(Center => (10.0,10.0), Width => 4.0, Height => 3.0);
S1.Draw;   -- 动态分派,调用 Circle 的 Draw
S2.Draw;   -- 动态分派,调用 Rectangle 的 Draw

内存布局Shape_Ptr 本身是一个指针(4或8字节),指向堆上分配的对象。对象头部包含 tag,因此通过指针访问时,动态分派仍然有效。

2.2 容器中的多态元素

使用 access T'Class 可以构建异构容器。例如,一个链表可以存储不同类型的形状:

type Node;
type Node_Ptr is access Node;
type Node is record
   Shape : Shape_Ptr;   -- 指向任意形状
   Next  : Node_Ptr;
end record;

遍历容器时,调用 Node.Shape.Draw 将根据实际类型动态分派。这是面向对象容器的基础。

2.3 注意事项:生命周期与内存泄漏

使用 access T'Class 时,必须明确谁负责释放对象。如果容器负责释放,则需要在删除节点时调用 Ada.Unchecked_Deallocation 释放 Shape 对象。否则会造成内存泄漏。同时,避免悬挂指针:释放后应将指针置为 null


三、通用访问类型与匿名访问

3.1 access all:可指向任何 aliased 对象

普通的访问类型(如 access Integer)只能指向堆上分配的对象(通过 new),不能指向栈上的局部变量。access all 则放宽了限制,可以指向任何 aliased 的对象(包括栈变量和全局变量)。这在需要临时引用局部数据时很有用,但必须确保引用不超过被引用对象的生命周期。

type Int_Acc is access all Integer;
X : aliased Integer := 10;
P : Int_Acc := X'Access;   -- 合法,X 是 aliased

危险示例:返回局部变量的 'Access 会导致悬挂指针,编译器通常能检测并拒绝(在安全模式下)。Ada 的作用域规则会阻止这种逃逸,但使用 access all 时需格外小心。

3.2 匿名访问类型:作为子程序参数

匿名访问类型直接在子程序参数中使用 access 关键字,无需单独命名类型。这常用于实现回调或临时访问。

procedure Process (Data : access Integer) is
begin
   Data.all := Data.all + 1;
end Process;

调用时,只能传递 aliased 变量的 'Access

X : aliased Integer := 5;
Process (X'Access);

匿名访问不能用于声明变量(只能用于参数),因此更安全,不会产生悬挂指针(因为参数生命周期受限于子程序)。

3.3 对比总结

特性普通 access Taccess all T匿名 access 参数
可指向堆对象是(需通过 new'Access
可指向栈变量是(需 aliased是(需 aliased
可声明变量否(仅参数)
典型用途动态数据结构临时引用、全局变量回调、内部辅助

四、访问类型在容器和回调中的应用

4.1 动态数组的简化实现

使用访问类型可以实现类似 C++ std::vector 的动态数组。一种常见模式:记录中包含指向堆数组的指针和长度、容量。

type Int_Array is array (Positive range <>) of Integer;
type Int_Array_Acc is access Int_Array;

type Vector is record
   Data : Int_Array_Acc;
   Len  : Natural := 0;
   Cap  : Positive;
end record;

初始化时分配 new Int_Array (1 .. Initial_Capacity),当长度超过容量时重新分配更大的数组并复制元素。这种模式避免了每次添加都重新分配,提高了效率。

4.2 回调机制:函数指针

Ada 允许访问类型指向子程序,这称为“访问子程序类型”。可用于实现回调、策略模式等。

type Callback is access procedure (Value : Integer);
-- 或者带返回值的函数
type Comparator is access function (A, B : Integer) return Boolean;

procedure Sort (Arr : in out Int_Array; Less : Comparator) is ...;

实例化时,使用 'Access 获取子程序的访问值,要求子程序在作用域内且满足调用约定。

function Ascending (A, B : Integer) return Boolean is (A < B);
Sort (My_Array, Ascending'Access);

安全性:访问子程序类型不能指向局部子程序(因为局部子程序的生命周期随栈帧结束),这防止了悬空回调。

4.3 回调与动态分派结合

可以将访问子程序类型作为标记记录的组件,实现类似“委托”或“策略”模式。例如,一个 Button 类可以包含一个 OnClick 回调,用户可动态设置。


五、内存管理策略

Ada 默认不提供垃圾回收,动态内存需要程序员手动管理。但 Ada 提供了多种机制来简化管理并提高安全性。

5.1 手动释放:Ada.Unchecked_Deallocation

最基础的方式,如同第14课所述。需要为每种访问类型实例化一个释放过程。缺点:容易忘记释放、产生悬挂指针。

procedure Free_Int is new Ada.Unchecked_Deallocation (Integer, Int_Ptr);

5.2 存储池(Storage Pools)

存储池允许自定义内存分配策略。Ada 为每个访问类型关联一个存储池,默认是 System.Pool_Global(类似 malloc/free)。通过创建自己的存储池,可以实现:

  • 从固定大小的静态缓冲区分配(适用于嵌入式系统,无堆碎片)。
  • 自动引用计数(通过池的 AllocateDeallocate 跟踪)。
  • 内存调试(记录分配/释放的调用栈)。

定义存储池需要继承 System.Storage_Pools.Root_Storage_Pool 并重写 AllocateDeallocateStorage_Size 函数。然后将访问类型的 Storage_Pool 属性设置为此池。

type My_Pool is new Root_Storage_Pool with private;
for Int_Ptr'Storage_Pool use My_Pool;

存储池较为底层,通常用于嵌入式或高性能场景。标准库中提供了 Ada.Unchecked_Deallocation 已经足够多数情况。

5.3 引用计数:Ada.Containers.Reference_Counted(Ada 2012+)

Ada 2012 引入的 Ada.Containers.Reference_Counted 提供了对引用计数智能指针的支持。它类似于 C++ 的 shared_ptr。使用 Reference_Counted.Types 中的 Holder 类型,可以自动管理引用计数,当最后一个引用消失时释放对象。

with Ada.Containers.Reference_Counted;
package Int_Holders is new Ada.Containers.Reference_Counted (Integer);
use Int_Holders;

P : Holder := To_Holder (new Integer'(42));
Q : Holder := P;   -- 引用计数增加
-- 当 P 和 Q 都离开作用域时,对象自动释放

引用计数避免了手动释放,但需注意循环引用问题(可通过弱引用解决,但 Ada 标准库未提供)。对于树或图结构,引用计数不适用,仍需手动管理或使用其他策略。

5.4 基于作用域的资源管理:Ada.Finalization

对于需要确定性释放的资源(如文件句柄),可以使用 Controlled 类型,在 Finalize 中释放资源。这是一种 RAII(资源获取即初始化)模式,类似于 C++ 的析构函数。这对于管理堆内存同样有效:在 Initialize 中分配,在 Finalize 中释放,然后将该对象作为栈上的组件,离开作用域自动释放。

type Smart_Int_Ptr is new Ada.Finalization.Controlled with record
   P : Int_Ptr;
end record;

overriding procedure Finalize (S : in out Smart_Int_Ptr) is
   procedure Free is new Ada.Unchecked_Deallocation (Integer, Int_Ptr);
begin
   Free (S.P);
end Finalize;

这样,声明 Smart_Int_Ptr 变量时,无需显式释放。这是推荐的做法。


六、安全性限制与最佳实践

6.1 aliased 的必要性

只有标记为 aliased 的对象才能被 'Access 引用。这迫使程序员思考是否真的需要指针,减少了无意的别名。

6.2 not null 约束

从 Ada 2005 开始,可以声明非空的访问类型:

type Non_Null_Ptr is not null access Integer;

这样的变量不能赋值为 null,减少了空指针解引用错误。在子程序参数中,access 默认允许 null,但可以用 not null access 强制非空。

6.3 池访问类型(Pool-specific access)

使用 new 分配的对象默认从全局堆分配。如果希望从特定存储池分配,可以使用 Storage_Pool 属性。还有“池访问类型”(access T 可以指定 Storage_Pool),但较少用。

6.4 避免悬挂指针的规则

  • 不要从子程序返回局部变量的 'Access(编译器通常阻止)。
  • 释放对象后,将所有指向它的指针置为 null
  • 优先使用智能指针(引用计数或 Controlled)代替手动管理。

6.5 使用场景建议

  • 动态数据结构(链表、树):手动管理或使用 Controlled 包装节点。
  • 多态容器access T'Class 配合 Controlled 自动释放。
  • 回调:匿名访问子程序参数或 access all 子程序类型,注意生命周期。
  • 性能关键路径:避免动态分配,或使用自定义存储池。

七、完整示例:多态形状容器(自动释放)

-- 文件名:shape_container.ads
with Ada.Finalization;
with Shapes; use Shapes;

package Shape_Container is
   type Shape_List is new Ada.Finalization.Controlled with private;
   
   procedure Add (List : in out Shape_List; S : access Shape'Class);
   procedure Display_All (List : Shape_List);
   
private
   type Node;
   type Node_Acc is access Node;
   type Node is record
      Shape : access Shape'Class;  -- 多态指针
      Next  : Node_Acc;
   end record;
   
   type Shape_List is new Ada.Finalization.Controlled with record
      Head : Node_Acc;
   end record;
   
   overriding procedure Initialize (List : in out Shape_List);
   overriding procedure Adjust (List : in out Shape_List);
   overriding procedure Finalize (List : in out Shape_List);
end Shape_Container;
-- 文件名:shape_container.adb
with Ada.Unchecked_Deallocation;

package body Shape_Container is
   procedure Free_Node is new Ada.Unchecked_Deallocation (Node, Node_Acc);
   
   procedure Add (List : in out Shape_List; S : access Shape'Class) is
      New_Node : Node_Acc := new Node'(Shape => S, Next => List.Head);
   begin
      List.Head := New_Node;
   end Add;
   
   procedure Display_All (List : Shape_List) is
      Current : Node_Acc := List.Head;
   begin
      while Current /= null loop
         Current.Shape.Draw;   -- 动态分派
         Current := Current.Next;
      end loop;
   end Display_All;
   
   overriding procedure Initialize (List : in out Shape_List) is
   begin
      List.Head := null;
   end Initialize;
   
   overriding procedure Adjust (List : in out Shape_List) is
      -- 深拷贝:需要复制整个链表,且复制 Shape 对象
      -- 为简化,本例不支持复制,故留空或 raise
   begin
      null; -- 实际应实现深拷贝,但超出范围
   end Adjust;
   
   overriding procedure Finalize (List : in out Shape_List) is
      Current : Node_Acc := List.Head;
      Next_Node : Node_Acc;
      procedure Free_Shape is new Ada.Unchecked_Deallocation (Shape'Class, access Shape'Class);
   begin
      while Current /= null loop
         Next_Node := Current.Next;
         Free_Shape (Current.Shape);   -- 释放形状对象
         Free_Node (Current);
         Current := Next_Node;
      end loop;
   end Finalize;
end Shape_Container;
-- 文件名:main.adb
with Shape_Container; use Shape_Container;
with Shapes; use Shapes;

procedure Main is
   List : Shape_List;
begin
   Add (List, new Circle'(Center => (0.0,0.0), Radius => 5.0));
   Add (List, new Rectangle'(Center => (10.0,10.0), Width => 4.0, Height => 3.0));
   Display_All (List);
   -- 离开作用域时,List 自动释放所有节点和形状对象
end Main;

八、本课总结

  • access T'Class 是多态指针,用于构建异构容器和动态分派。
  • 通用访问类型 access all 可以指向任何 aliased 对象,但需谨慎生命周期。
  • 匿名访问参数(access)适合回调,更安全。
  • 访问子程序类型实现函数指针,支持策略模式。
  • 内存管理策略:手动释放、存储池、引用计数、Controlled RAII。推荐使用 Controlled 或引用计数减少错误。
  • 安全性限制(aliasednot null、作用域规则)是 Ada 指针安全的基石。

九、课后练习

  1. 多态指针练习:声明一个 Shape_Ptr 指向 Shape'Class,动态分配一个 Circle 和一个 Rectangle,并调用它们的 AreaDraw。释放内存。

  2. 匿名访问参数:编写一个过程 Apply,接受一个匿名访问 Integer 参数和一个函数指针(访问函数类型),将函数应用到该整数上并更新。测试。

  3. 通用访问类型:声明一个 access all Integer 变量,分别指向堆对象和栈上的 aliased 变量。解释各自的生命周期注意事项。

  4. 动态数组实现:实现一个泛型 Vector 包,内部使用访问类型管理动态数组,支持 AppendPopGetLength。要求使用 Controlled 自动释放内存。

  5. 回调与策略模式:实现一个排序过程,接受一个 Comparator 访问函数类型(access function (A,B:Integer) return Boolean)。测试使用升序和降序比较器。

  6. 存储池探索:编写一个自定义存储池,从固定大小的静态缓冲区分配,并绑定到一个访问类型。分配几个对象,观察地址是否在缓冲区内。

  7. 引用计数实验:使用 Ada.Containers.Reference_Counted 创建一个 Integer 的引用计数句柄,测试多个句柄指向同一对象时自动释放。

  8. 循环引用问题:设计一个双向链表,节点包含前后指针。使用引用计数管理节点,观察是否发生内存泄漏(因为循环引用导致计数永不为零)。提出解决方案(如弱指针)。

  9. 悬挂指针检测:编写一个函数,返回一个局部变量的 'Access,观察编译器是否报错。尝试使用 access all 绕过,并解释风险。

  10. 性能比较:实现一个频繁插入删除的链表,分别使用手动释放和 Controlled 自动释放,测试性能(使用 Ada.Calendar 计时)。分析开销。


十、下节预告

第23课|异常与错误处理深度实践

我们将:

  • 深入异常传播机制与异常规范
  • 学习异常与资源管理的结合(RAII)
  • 掌握自定义异常链与异常消息
  • 应用异常处理构建健壮系统

关键术语表

多态指针access T'Class,指向类宽类型的指针,支持动态分派。

通用访问类型access all,可指向任何 aliased 对象的访问类型。

匿名访问:子程序参数中直接使用的 access,无类型名,用于回调。

访问子程序类型:指向子程序的访问类型,用于回调或策略模式。

存储池(Storage Pool):管理访问类型内存分配的后台对象,可自定义。

引用计数(Reference Count):通过计数跟踪对象引用,自动释放。

Ada.Finalization.Controlled:提供初始化、调整、终结操作的混合类型,用于 RAII。

aliased:标记对象可以被 'Access 引用。

not null:约束访问类型不能为空。