返回 2026-04-13
⚙️ 工程

Ada 中的面向对象编程Object Oriented Programming in Ada

文章探讨了 Ada 语言中实现面向对象编程(OOP)的机制,重点介绍了其支持封装、继承和多态的方式。作者指出,Ada 通过记录类型(records)、访问类型(access types)和泛型(generics)等特性实现了类式结构,同时保持了语言的强类型和安全性优势。尽管 Ada 并非传统意义上的 OOP 语言,但其设计允许开发者以接近现代面向对象风格的方式组织代码。

kqr

Ada 的设计极其精妙。一个显著的体现是,它将其他语言中庞大而整体的功能拆解为各个组成部分,让我们可以按需选择使用哪些部分。我经常用面向对象编程来解释这一点。

面向对象编程(部分)

直到学习了 Ada,我才真正理解了面向对象编程。Ada 将面向对象编程拆分为若干独立特性,例如:

  • 封装,
  • 复用,
  • 继承,
  • 抽象接口,
  • 类型扩展,以及
  • 动态分派。
  • 在 Java 这类语言中,我们只需声明关键字 `class`,就会自动包含所有这些特性——无论你是否需要。相比之下,在 Ada 中,这些特性是可选的,我们可以单独启用。我更偏好 Ada 对所用语言特性的细粒度控制,这不仅有助于编写易于维护的代码,还能让我成为更好的程序员。

    一位朋友感到好奇,让我把一个小型的 Java 示例翻译成 Ada 以便比较。结果这个例子并没有起到很好的说明作用,但或许仍能满足某些人的好奇心。

    一个带有两个实现的接口

    提供的 Java 程序首先创建了一个车辆引擎的类层次结构。

    In[1]:

    interface Engine {
        public void run();
    }

    这可以相当直接地翻译为 Ada,主要区别在于 Ada 将包拆分为包规范和包体两个独立块。这会导致一些代码重复,但在大型项目中我发现,能够一目了然地看到规范非常有用。你可以把包规范想象成 C 语言的头文件,但需要注意的是,Ada 的包规范更加结构化,并且会被编译器实际用于辅助程序员。

    首先是规范:22 抱歉缺乏语法高亮。目前我没有安装 Ada 模式。

    In[2]:

    package Engines is
    
      type Engine is interface;
      procedure Run (Self : Engine) is abstract;
    
    end Engines;

    我们将 Engine 定义为抽象接口,并为其添加一个 Run 方法。Run 方法实际上只是一个普通的过程,但 Ada 有一些规则用于确定哪些过程是原始操作,所有原始操作都被视为属于其接口或类的“方法”。这里只有 Run,并且它是公共的,因为包规范中的内容默认就是公共的。

    然后 Java 代码添加了该接口的两个实现。

    In[3]:

    class TwoStroke implements Engine {
        public void run() {
            System.out.println("bam bam bababam bam bam...");
        }
    }
    
    class V8 implements Engine {
        public void run() {
            System.out.println("dadadadadadada...");
        }
    }

    在 Ada 中,我们在包规范中添加相应的代码。

    In[4]:

    package Engines is
      ------->8-------
    
      type Two_Stroke is new Engine with null record;
      overriding procedure Run (Self : Two_Stroke);
    
      type V8 is new Engine with null record;
      overriding procedure Run (Self : V8);
    
    end Engines;

    当我们说子类型是带有空记录的新引擎时,是在告诉 Ada 我们不希望在扩展对象时添加任何字段。许多语言默认假设不会发生任何事情(就像上面的 Java 代码假设我们希望在不添加数据的情况下进行扩展),而 Ada 总是要求程序员明确请求不添加任何内容,如果这就是他们的意图。这使得某些 bug 更难编写。

    我们还必须实现这些方法,而这发生在包体中。

    In[5]:

    with Ada.Text_IO;
    
    package body Engines is
    
      procedure Run (Self : Two_Stroke) is
      begin
        Ada.Text_IO.Put_Line ("bam bam bababam bam bam...");
      end Run;
    
      procedure Run (Self : V8) is
      begin
        Ada.Text_IO.Put_Line ("dadadadadadada...");
      end Run;
    
    end Engines;

    回想一下,Run 是一个常规过程,只要 My_Engine 是具体类型(如 Two_Stroke),我们就可以直接调用它 Run(My_Engine)。只有当 My_Engine 的真实类型未知(因为它可能是任何实现了 Engine 接口的类型)时,才需要使用面向对象的语法 My_Engine.Run 来调用它。这种语法执行动态分派,只有在标记过的类型上才可能实现。声明为接口的类型会自动被标记。

    Engine 内存管理

    太好了!接下来是定义车辆。我们稍后会详细展开,但这是从 Java 代码中得出的最初关键步骤。

    In[6]:

    class Vehicle {
        protected int size;
        protected Engine engine;
    
        public void drive() {
           this.engine.run();
        }
    }

    如果我们现在开始编写 Ada 代码,就会意识到我们希望车辆能够容纳任何实现了 Engine 接口的类型。这意味着编译器无法预先知道 Vehicle 类型的大小。Java 并不关心这一点,因为它总是假设 Engine 是一个指向堆分配类型的引用,但在 Ada 中,我们必须明确这一点,因为 Ada 允许我们将数据存储在栈上。

    Ada 代码中的简单解决方案是效仿 Java 的做法,在 Vehicle 类型中存储一个单独分配的 Engine 对象的引用。于是我们回到 Engines 包,添加支持该功能所需的代码。

    In[7]:

    with Ada.Unchecked_Deallocation;
    
    package Engines is
      ------->8-------
    
      type Engine_Access is access all Engine'Class;
      procedure Free is new Ada.Unchecked_Deallocation
        (Engine'Class, Engine_Access);
    
      ------->8-------
    end Engines;

    这里我们声明 Engine_Access 为对任何实现 Engine 接口的类型的引用。Ada 拥有类似 C 语言的指针,但通常应避免使用。Ada 中使用的各种访问类型是更安全的选择,因为它们通过巧妙的规则限定作用域,使得难以泄漏那些可能超出作用域的引用。

    然而在此例中,我们模仿 Java 代码,创建一个全局的访问类型,可以引用任何地方的对象。因此,我们还为此类型创建了一个 Unchecked_Deallocate 过程的具体实例,以便稍后释放任何动态分配的引擎对象。

    Some_Type'Class 语法表示“从 Some_Type 开始的整个类层次结构”。

    搞定这些之后,我们就可以完成 Vehicle 类型的 Ada 代码了。首先是包规范部分。

    In[8]:

    with Ada.Finalization;
    with Engines;
    
    package Vehicles is
    
      type Vehicle (<>) is tagged limited private;
      procedure Drive (Self : Vehicle);
    
    private
    
      type Vehicle is new Ada.Finalization.Limited_Controlled with
      record
        Size : Integer;
        Engine : Engines.Engine_Access;
      end record;
    
      overriding procedure Finalize (Self : in out Vehicle);
    
    end Vehicles;

    这里有很多内容,其中大部分都与内存管理有关。可能存在一种方法可以通过判别式(discriminant)简化代码,从而摆脱这些复杂操作并完全依赖静态分配,但我的 Ada 功力尚浅,暂时想不出具体做法。

    首先,我们声明一个通用的 Vehicle 类型。我们为其设置一个未知的判别式(即 (<>) 部分),这阻止其被隐式分配(例如当它被用作栈变量时);实际上这会迫使使用者必须通过构造函数来创建它。我们将其标记为 tagged,以启用动态分派。同时将其设为 limited,防止赋值操作,否则会隐式复制 Engine 引用,导致多个车辆共享同一个引擎实例。另一种选择是将其设为非 limited,并在 Ada.Finalization.Controlled 中实现 Adjust 方法。Adjust 方法会在赋值时自动调用,负责处理复制过程中涉及的资源管理。为了节省篇幅,本文暂不采用此方案。目前它只有一个方法:Drive。

    包的私有部分是仅对该包及其子包可见的部分。在这里我们指定 Vehicle 类型的实际结构。它继承自 Ada.Finalization.Limited_Controlled,使其成为受控类型(稍后将详细说明),并定义其数据字段。与 Java 代码一样,Engine 字段的类型为指向引擎的引用。由于 Vehicle 是受控类型,它会继承一个 Finalize 过程供我们重写,因此我们在本节中声明该方法。

    哦对了,Finalize 方法中 Self 参数的 in out 模式表示该方法可以修改它所调用的对象。参数默认使用 in 模式,表示只读;也可以要求 out 模式,表示该方法可以向该参数赋值,但不能从中读取值。

    该包体的实现如下:

    In[9]:

    package body Vehicles is
    
      procedure Finalize (Self : in out Vehicle) is
      begin
        Engines.Free (Self.Engine);
      end Finalize;
    
      procedure Drive (Self : Vehicle) is
      begin
        Self.Engine.Run;
      end Drive;
    
    end Vehicles;

    Ada 理解,尽管 Self.Engine 是对一个引擎的引用,但我们希望调用它所指向对象的 Run 方法,而不是引用本身的方法。

    这里新出现的是 Finalize 方法。在 Ada 中,受控类型(controlled type)提供了类似 C++ 中 RAII 的功能。当一个受控类型(即实现了 Controlled 或 Limited_Controlled 接口的类型)进入作用域时,编译器会自动调用其 Initialize 方法;当它离开作用域时,编译器会自动调用其 Finalize 方法。我们在这里使用后者来释放车辆创建过程中可能分配的任何引擎,以防止内存泄漏。

    Ada 没有垃圾回收机制,也不推崇 C 风格的 malloc/free/sbrk/mmap 内存管理方式——这种方式容易出错。在常规的 Ada 代码中,通常可以完全避免使用类似 malloc 的动态内存管理。例如,由于 Ada 中的访问类型(引用)是有作用域的,因此在 Ada 中栈分配比在其他语言中更安全——指向栈分配值的引用不会泄露到其有效范围之外。Ada 还支持基于存储池的分配方式。

    但!在这个例子中确实使用了 malloc 风格的分配,因此我们也需要注意及时释放内存。虽然在 C 语言中这由程序员负责,但 Ada 至少通过提供在对象变得不可达之前自动调用 Finalize 方法的受控类型来帮助我们,这一点类似于 C++。

    交互对象

    Java 代码并没有就此结束。这是完整的 Vehicle 对象。

    In[10]:

    class Vehicle {
        protected int size;
        protected Engine engine;
    
        public void drive() {
            this.engine.run();
        }
    
        public void crash(Vehicle other) {
            this.drive();
            other.drive();
    
            if (this.size + other.size > 25) {
                System.out.println("CRASH!");
            } else {
                System.out.println("bonk!");
            }
    
            other.angryReaction();
        }
    
        public void angryReaction() {}
    }

    当两辆车相撞时,它们首先行驶,然后有一些逻辑用于判断碰撞的严重程度。最后,碰撞的接收方可能会根据所乘坐的车辆类型喊出一些脏话。

    我们在 Ada 包规范中添加必要的部分。

    In[11]:

    package Vehicles is
      ------->8-------
    
      procedure Crash (Self : Vehicle; Other : Vehicle'Class);
      procedure Angry_Reaction (Self : Vehicle) is null;
    
      ------->8-------
    end Vehicles;

    以及包体。

    In[12]:

    with Ada.Text_IO;
    
    package body Vehicles is
      ------->8-------
    
      procedure Crash (Self : Vehicle; Other : Vehicle'Class) is
      begin
        Self.Drive;
        Other.Drive;
    
        if Self.Size + Other.Size > 25 then
          Ada.Text_IO.Put_Line ("CRASH!");
        else
          Ada.Text_IO.Put_Line ("bonk!");
        end if;
    
        Other.Angry_Reaction;
      end Crash;
    
    end Vehicles;

    值得注意的是,虽然 Ada 对第一个参数执行动态分派(即在调用的 Vehicle 子类型上找到最具体的方法),但我们必须明确要求第二个参数是任何 Vehicle'Class 类型的,否则 Ada 会期望它是 Vehicle 类型,而不是任何子类型。

    这与 Java 代码并没有本质区别。在 Java 中,当我们说 Vehicle 时,它自动意味着 Vehicle'Class。而在 Ada 中,我们可以选择明确要求 Vehicle 类型,而不是其子类型——这实际上是默认行为。这也是 Ada 让我们有意识地选择想要使用的 OOP 特性之一,而不是默认全部启用。

    声明子类型

    Java 代码随后定义了一种车辆类型,它具有尺寸和发动机类型,并重写了其反应行为。55 注意,这些都不是良好的面向对象设计!我并不是想在这里讲授设计原则。我只是收到某人好奇的例子,并尽量忠实于原样。

    In[13]:

    class Moped extends Vehicle {
        Moped() {
            this.size = 10;
            this.engine = new TwoStroke();
        }
    
        public void angryReaction() {
            System.out.println("Hey! You twisted my wheel!");
        }
    }

    由于这些字段在 Java 代码中是受保护的,子类型可以看到它们。Ada 通过包系统控制字段可见性,而不是类——这是 Ada 让我们逐步选择性地使用 OOP 的优点之一,而不是一下子全部暴露出来。

    为了让子类型能够访问父类型的字段,我们将子类型声明在与父类型相同的包中。66 别担心,这并不意味着无法扩展第三方库。包的层级是开放的,子包同样可以访问其父包的内部实现。

    In[14]:

    package Vehicles is
      ------->8-------
    
      type Moped (<>) is new Vehicle with private;
      function Create_Moped return Moped;
      overriding procedure Angry_Reaction (Self : Moped);
    
    private
      ------->8-------
    
      type Moped is new Vehicle with null record;
    
    end Vehicles;

    包体应该看起来很熟悉。

    In[15]:

    package body Vehicles is
      ------->8-------
    
      function Create_Moped return Moped is
      begin
        return (
          Ada.Finalization.Limited_Controlled with
            Size => 10,
            Engine => new Engines.Two_Stroke
        );
      end;
    
      procedure Angry_Reaction (Self : Moped) is
      begin
        Ada.Text_IO.Put_Line ("Hey! You twisted my wheel!");
      end Angry_Reaction;
    end Vehicles;

    正如你所读到的,Ada 没有内置构造函数。任何返回对象的函数都可以用作构造函数。77 话虽如此,这是 Ada 语言设计中的一个不令人愉快的角落。我们能在当前代码中这样做,是因为没有任何内容继承自我们的具体车辆子类型。如果继承了它们,就会发现它们的构造函数(作为原始操作,还记得吗!)也会被继承,这显然是不希望的。虽然有一些解决方法,最常见的是在子包中定义构造函数,以防止编译器将其视为原始操作。要构造一辆 Moped,我们首先申请一个 Limited_Controlled,然后为需要初始化的字段赋值。这里我们还使用 new 关键字以类似 malloc 的方式动态分配 Two_Stroke 给这辆摩托车。

    在子类型中扩展数据字段

    示例还需要一样东西:另一种车辆。

    In[16]:

    class Car extends Vehicle {
        private String color;
    
        Car(String color) {
            this.size = 50;
            this.engine = new V8();
            this.color = color;
        }
    
        public void angryReaction() {
            System.out.printf(
                "Hey! You scratched my %s paint!\n",
                this.color
            );
        }
    }

    到现在为止,我认为 Ada 代码大部分都不会让人困惑。唯一的新内容是:这个子类型在父类型的基础上添加了一个父类型中不存在的数据字段——而这个字段仅对该子类型私有。我们在 vehicle 包的规范中添加以下内容:

    In[17]:

    with Ada.Strings.Unbounded;
    use Ada.Strings.Unbounded;
    ------->8-------
    
    package Vehicles is
      ------->8-------
    
      type Car (<>) is new Vehicle with private;
      function Create_Car (Colour : Unbounded_String) return Car;
      overriding procedure Angry_Reaction (Self : Car);
    
    private
      ------->8-------
    
      type Car is new Vehicle with
      record
        Colour : Unbounded_String;
      end record;
    
    end Vehicles;

    以及包体

    In[18]:

    package body Vehicles is
      ------->8-------
    
      function Create_Car (Colour : Unbounded_String) return Car is
      begin
        return (
          Ada.Finalization.Limited_Controlled with
            Size => 50,
            Engine => new Engines.V8,
            Colour => Colour
        );
      end;
    
      procedure Angry_Reaction (Self : Car) is
      begin
        Ada.Text_IO.Put_Line (
          "Hey! You scratched my " & To_String (Self.Colour) & " paint!"
        );
      end Angry_Reaction;
    
    end Vehicles;

    Ada 中的普通字符串值是静态分配的,这意味着我们必须知道字符串的长度才能在记录字段中使用它(或者将这个决定推迟到上层)。为了摆脱这种麻烦,我们使用标准库中的 Unbounded_String,它是动态分配的,类似于 C++ 中的 std::string。

    整合起来

    经过所有这些铺垫后,终于到了高潮部分。Java 代码的 main 函数说

    In[19]:

    public class inheritance {
        public static void main(String[] args) {
            Moped moped = new Moped();
            Car car = new Car("blue");
    
            System.out.println("car crashes into car:");
            car.crash(car);
            System.out.println();
    
            System.out.println("moped crashes into car:");
            moped.crash(car);
            System.out.println();
    
            System.out.println("car crashes into moped:");
            car.crash(moped);
            System.out.println();
    
            System.out.println("moped crashes into moped:");
            moped.crash(moped);
            System.out.println();
        }
    }

    在 Ada 中,我们有非常相似的写法

    In[20]:

    with Ada.Text_IO;
    with Ada.Strings.Unbounded;
    use Ada.Strings.Unbounded;
    with Engines;
    with Vehicles;
    
    procedure Main is
      package IO renames Ada.Text_IO;
    
      Moped : Vehicles.Moped := Vehicles.Create_Moped;
      Car : Vehicles.Car := Vehicles.Create_Car (
        To_Unbounded_String("blue")
      );
    begin
      IO.Put_Line ("car crashes into car:");
      Car.Crash (Car);
      IO.Put_Line ("");
    
      IO.Put_Line ("moped crashes into car:");
      Moped.Crash (Car);
      IO.Put_Line ("");
    
      IO.Put_Line ("car crashes into moped:");
      Car.Crash (Moped);
      IO.Put_Line ("");
    
      IO.Put_Line ("moped crashes into moped:");
      Moped.Crash (Moped);
      IO.Put_Line ("");
    end Main;

    完整代码可在 SourceHut 上的未列出 adaoopexa 仓库中找到。

    反射(不是那种!)

    这次练习并没有像我期望的那样充分展示 Ada 的强大之处。部分原因在于,Ada 的许多优点都体现在各个特性之间精妙而微妙的交互中,这些特性本就是为协同工作而设计的。尽管我已经尽力暗示这些特性,但在这类示例中很难真正体现出来。

    此外,Ada 的一些强大功能恰恰出现在我们并不试图在其中复制类层次结构的时候。能够在不创建完整类层次结构的情况下,通过包的私有区域隐藏实现和数据,这一点非常棒。

    但最重要的是,Ada 是与 C 竞争,而不是与 Java。想象一下要在 C 中实现上述功能。我其实不必想象,因为我见过有人尝试。88 我不会分享那段代码,因为作者并未同意公开。如果有人愿意分享自己的尝试,我很乐意接收。那段 C 代码比 Ada 代码更长——而 Ada 本身已经是一门冗长的语言了——而且它之所以能运行,仅仅是因为这个例子恰好满足条件。一旦需求发生变化,C 代码极有可能陷入混乱的错误、内存泄漏,或两者兼有。

    需要完整排版与评论请前往来源站点阅读。