类
D的面向对象的特性都来源于类。处在类层次最顶点的是类 Object。Object 定义了每个派生类拥有的最小功能集合,并为这些功能提供了默认的实现。
类是程序员定义的类型。作为面向对象语言,D支持对象,支持封装、继承和多态。D 类支持单继承编程范式,并且支持接口。类对象只能通过引用具现化。
类可以被导出,这意味着它的名字和非私有成员将会暴露给外部的 DLL 或者 EXE 。
类声明定义为:
类声明: class 标志符 [父类{, 接口类 }] 类过程体 父类: : 标志符 接口类: 标志符 类过程体: { 类过程体声明 } 类过程体声明: 声明 构造函数 析构函数 静态构造函数 静态析构函数 不变量 单元测试 类分配器 类释放器
ClassDeclaration: class Identifier [SuperClass {, InterfaceClass }] ClassBody SuperClass: : Identifier InterfaceClass: Identifier ClassBody: { ClassBodyDeclarations } ClassBodyDeclaration: Declaration Constructor Destructor StaticConstructor StaticDestructor Invariant UnitTest ClassAllocator ClassDeallocator
类的组成:
- 父类
- 接口
- 动态域
- 静态域
- 类型
- 函数
- 接口
-
- 静态函数
- 动态函数
- 构造函数
- 析构函数
- 静态构造函数
- 静态析构函数
- 不变量
- 单元测试
- 分配器
- 释放器
- 动态函数
- 静态函数
类定义:
class Foo { ... 成员 ... }
注意在标志类定义结束的‘ }’之后没有‘;’。同样不能像下面这样声明变量:
class Foo { } var;
应该这样声明:
class Foo { } Foo var;
域
类成员总是通过‘.’运算符访问。没有 C++ 中的‘::’或 ‘->’运算符。
D 编译器有权利重新排列类中各个域的顺序,这样就允许编译器按照实现定义的方式将它们压缩以优化程序。因此,对齐语句,匿名结构以及匿名联合不允许在类中出现,因为它们依赖于数据的排列方式。考虑函数中的局部变量的域——编译器将其中一些分配到寄存器中,其他的按照最理想的分布被保存到堆栈帧中。这就给了代码设计者重排代码以加强可读性的自由,而不必强迫代码设计者 依据机器的优化规则排列相关的域。结构/联合提供了显式控制域分布的能力,但这不是类的分内之事。
父类
所有的类都从父类继承。如果没有指定,它就继承 Object 。Object 是 D 类层次体系的根。
构造函数
构造函数: this() 语句块
Constructor: this() BlockStatement
所有的成员都被初始化为它对应类型的默认初始值,通常整数被初始化为 0 ,浮点数被初始化为 NAN。这就防止了因为在某个构造函数中忽略了初始化某个成员而造成那已发现的错误。在类定义中,可以使用静态的初始值代替默认值:
class Abc { int a; // a 的默认初始值为 0 long b = 7; // b 的默认初始值为 7 float f; // f 的默认初始值为 NAN }
静态初始化在调用构造函数之前完成。
构造函数是名为 this 的函数,它没有返回值:
class Foo { this(int x) // 声明 Foo 的构造函数 { ... } this() { ... } }
基类的构造通过用 super 调用基类的构造函数来完成:
class A { this(int y) { } } class B : A { int j; this() { ... super(3); // 调用基类构造函数 A.this(3) ... } }
构造函数也能调用同一个类中的其他构造函数以共享通用的初始化代码:
class C { int j; this() { ... } this(int i) { this(); j = i; } }
如果构造函数中没有通过 this 或 super 调用构造函数,并且基类有构造函数,编译器将在构造函数的开始处自动插入一个 super() 。
如果类没有构造函数,但是基类有构造函数,那么默认的构造函数的形式为:
this() { }
这会由编译器隐式生成。
类对象的构造十分灵活,但是有一些限制:
- 构造函数互相调用是非法的:
this() { this(1); } this(int i) { this(); } // 非法,构造函数循环调用
- 如果一个构造函数中调用了构造函数,那么这个构造函数的任何执行路径中都只能调用一次构造函数:
this() { a || super(); } // 非法 this() { this(1) || super(); } // ok this() { for (...) { super(); // 非法,位于循环内 } }
- 在构造函数出现之前显式或者隐式引用 this 都是非法的。
- 不能在标号后调用构造函数(这样做的目的是使检查 goto 的前导条件容易完成)。
类对象的实例使用 New表达式 创建:
A a = new A(3);
在这个过程中按照下面的步骤执行:
- 为对象分配存储空间。如果失败,不会返回 null,会抛出一个 OutOfMemoryException 异常。因此,不再需要编写冗长而乏味的 null 引用防卫代码。
- 使用类定义中的值静态初始化“原始数据”。给指向虚函数表的指针赋值。这保证了调用构造函数的类是已经完全成型的。这个操作等价于使用 memcpy() 将对象的静态版本拷贝到新分配的对象的空间,但是更高级的编译器将会对这种方法进行优化。
- 如果为类定义了构造函数,匹配调用参数列表的构造函数被调用。
- 如果打开了类不变量的检查,在构造函数调用后调用类不变量。
析构函数
析构函数: ~this() 语句块
Destructor: ~this() BlockStatement
当对象被垃圾收集程序删除时将调用析构函数。语法如下:
class Foo { ~this() // Foo 的析构函数 { } }
每个类只能有一个析构函数,析构函数没有参数,没有特征。它总是虚函数。
此构函数的作用是释放对象持有的任何资源。
程序可以显式的通知垃圾收集程序不在引用一个对象(使用 delete 表达式),然后垃圾收集程序会立即调用析构函数,并将对象占用的内存放回自由存储区。析构函数决不会被调用两次。
当析构函数运行结束时会自动调用父类的析构函数。不能显式调用父类的析构函数。
当垃圾收集程序调用一个对象的析构函数时,并且这个对象含有对垃圾收集对象的引用,那么这些引用都会变得无效。这意味着析构函数不能引用子对象。这条规则并不适用于 auto 对象或使用 释放表达式 释放的对象。
垃圾收集程序不能保证对所有的无引用对象调用析构函数。而且垃圾收集程序也不能保证调用的相对顺序。
从数据段中引用的对象不会被垃圾收集程序收集。
静态构造函数
静态构造函数: static this() 语句块
StaticConstructor: static this() BlockStatement
静态构造函数在 main() 函数获得控制前执行初始化。静态构造函数用来初始化其值不能再编译时求出的静态类成员。
其他语言中的静态构造函数被设计为可以使用成员进行初始化。这样做的问题是无法精确控制代码执行的顺序。例如:
class Foo { static int a = b + 1; static int b = a * 2; }
最终 a 和 b 都是什么值?初始化按照什么顺序执行?在初始化前 a 和 b 都是什么值?这是一个编译是错误吗?抑或它是一个运行是错误?还有一种不那么明显的令人迷惑的情况是单独一个初始化是静态的还是动态的。
D 让这一切变得简单。所有的成员初始化都必须在编译时可确定,因此就没有求值顺序依赖问题,也不可能读取一个未被初始化的值。动态初始化由静态构造函数执行,采用语法 static this() 实现。
class Foo { static int a; // 默认初始化为 0 static int b = 1; static int c = b + a; // 错误,不是常量初始化 static this() // 静态构造函数 { a = b + 1; // a 被设为 2 b = a * 2; // b 被设为 4 } }
static this() 会由启动代码在调用 main() 之前调用。如果它正常返回(没有抛出异常),静态析构函数就会被加到会在程序终止时被调用的函数列表中。静态构造函数的参数列表为空。
当前的静态构造函数的缺点是调用它们的顺序是无定义的。因此,暂时来说,静态构造函数是依赖于声明顺序的。这个问题会在未来的版本中解决。
静态析构函数
静态析构函数: static ~this() 语句块
StaticDestructor: static ~this() BlockStatement
静态析构函数定义为具有语法形式 static ~this() 的特殊静态函数。
class Foo { static ~this() // 静态析构函数 { } }
静态析构函数在程序终止的时候被调用,但这只发生在静态构造函数成功执行完成时。静态析构函数的参数列表为空。静态析构函数按照静态构造函数调用的逆顺序调用。
类不变量
类不变量: invariant 语句块
ClassInvariant: invariant BlockStatement
类不变量用来指定类的必须总是为真的特征(除了在执行成员函数时)。例如,代表日期的类可能有一个不变量—— day 必须位于 1..31 之间,hour 必须位于 0..23 之间:
class Date { int day; int hour; invariant { assert(1 <= day && day <= 31); assert(0 <= hour && hour < 24); } }
类不变量就是一种契约,是必须为真的断言。当类的构造函数执行完成时、在类的析构函数开始执行时、在 public 或 exported 成员函数执行之前,或者在 public 或 exported 成员函数完成时,将检查不变量。
不变量中的代码不应该调用任何非静态公有类成员函数,无论直接还是间接。如果那样做的话就会造成堆栈溢出,因为不变量将被无限递归调用。
class Foo { public void f() { } private void g() { } invariant { f(); // 错误,不能在不变量中调用公有成员函数 g(); // ok,g() 不是公有的 } }
可以使用 assert()
检查类对象的不变量,例如:
Date mydate; ... assert(mydate); // 检查类 Date 的不变量
如果不变量检查失败,将抛出一个 InvariantException
异常。 类不变量会被继承,也就是,任何类的不变量都隐式地包含其基类的不变量。
每个类只能有一个 类不变量 。
当编译生成发布版时,不会生成不变量检查代码,这样程序就会以最高速度运行。
单元测试
单元测试: unittest 语句块
UnitTest: unittest BlockStatement
单元测试是用来测试一个类是否工作正常的一系列测试用例。理想情况下,单元测试应该在每次编译时运行一遍。确保做到这点的最好的方法是将它们同类的代码一同放到类的实现代码中,一起维护。
D 的类可以有一个特殊的成员函数,叫做:
unittest { ...测试代码... }
程序中所有类的 test() 函数将在静态初始化之后,主函数调用之前被调用。一个编译器或链接器开关将把测试代码从最终构建中删除。
例如,假定一个类 Sum 用来计算两个值得和:
class Sum { int add(int x, int y) { return x + y; } unittest { assert(add(3,4) == 7); assert(add(-2,0) == -2); } }
类分配器
类分配器: new 参数列表 语句块
ClassAllocator: new ParameterList BlockStatement
具有下面形式的类成员函数叫做类分配器:
new(uint size) { ... }
如果第一个参数的类型是 uint ,类分配器可以有任意数量的参数。可以为类定义多个类分配器,通过通用的函数重载解析规则选择合适的函数。当执行一个 new 表达式:
new Foo;
并且当 Foo 是拥有分配器的类时,分配器将被调用,第一个参数被设置为分配一个实例所需的以字节为单位的内存大小。分配器必须分配内存并返回一个 void* 指针。如果分配失败,它不必返回一个 null,但是必须抛出一个异常。如果分配器有多于一个的参数,余下的参数将在 NewExpression 之中的 new 之后的括号中出现:
class Foo { this(char[] a) { ... } new(uint size, int x, int y) { ... } } ... new(1,2) Foo(a); // 调用 new(Foo.size,1,2)
如果没有指定类分配器,派生类将继承基类的类分配器。
另见 显式类实例分配 。
类释放器
类释放器: delete 参数列表 语句块
ClassDeallocator: delete ParameterList BlockStatement
具有下面形式的类成员函数叫做类释放器:
delete(void *p) { ... }
类释放器有且仅有一个类型为 void* 的参数。一个类只能有一个类释放器。当执行一个 delete 表达式:
delete f;
且 f 是拥有释放器的一个类的实例时,如果类有析构函数,会调用析构函数,然后释放器被调用,一个指向类实例的指针被传递给释放器。释放内存是释放器的责任。
如果不特别指定,派生类会继承基类所有的释放器。
参见 显式类实例分配 。
auto 类
auto 类是带有 auto 特征的类,如下:
auto class Foo { ... }
auto 特征会被继承,所以任何从 auto 类继承的类都是 auto 的。
对 auto 类的引用只能作为函数局部变量出现,且这个引用必须被声明为 auto :
auto class Foo { ... } void func() { Foo f; // 错误,对 auto 类的引用必须为 auto auto Foo g = new Foo(); // 正确 }
当一个 auto 类的引用脱离作用域时,会自动调用它的析构函数(如果有的话)。即使是因为发生了异常而脱离作用域,也会保证这种行为发生。
接口
接口声明: interface 标志符 接口体 interface 标志符 : 父接口 接口体 父接口: 标志符 标志符 , 父接口 接口体: { 声明定义 }
InterfaceDeclaration: interface Identifier InterfaceBody interface Identifier : SuperInterfaces InterfaceBody SuperInterfaces Identifier Identifier , SuperInterfaces InterfaceBody: { DeclDefs }
接口描述了从接口派生的类必须实现的函数的列表。指向从一个接口派生的类的引用可以被转换为对这个接口的引用。接口对应于操作系统暴露的对象的接口,如 Win32 的 COM/OLE/ActiveX 。
接口不能从类派生,只能从其他接口派生。类不能多次从一个接口派生。
interface D { void foo(); } class A : D, D // 错误,多重接口 { }
不能创建接口的实例。
interface D { void foo(); } ... D d = new D(); // 错误,不能创建接口的实例
接口成员函数不能有实现。
interface D { void bar() { } // 错误,不允许实现 }
继承接口的类必须实现接口中的所有函数:
interface D { void foo(); } class A : D { void foo() { } // ok,提供了实现 } class B : D { int foo() { } // 错误,没有提供 void foo() 实现 }
接口可以被继承,其中的函数可以被重写:
interface D { int foo(); } class A : D { int foo() { return 1; } } class B : A { int foo() { return 2; } } ... B b = new B(); b.foo(); // 返回 2 D d = (D) b; // ok,因为 B 继承了 A 的 D 实现 d.foo(); // 返回 2
接口可以在派生类中重新实现:
interface D { int foo(); } class A : D { int foo() { return 1; } } class B : A, D { int foo() { return 2; } } ... B b = new B(); b.foo(); // 返回 2 D d = (D) b; d.foo(); // 返回 2 A a = (A) b; D d2 = (D) a; d2.foo(); // 返回 2,尽管它是 A 的 D,不是 B 的 D
如果类要重新实现接口,必须重新实现接口的所有函数,不能从父类中继承:
interface D { int foo(); } class A : D { int foo() { return 1; } } class B : A, D { } // 错误,接口 D 没有 foo()
COM 接口
接口的一个变体是 COM 接口。按照设计,COM 接口被为直接映射到 Windows COM 对象。任何 COM 对象都由一个 COM 接口表示,任何带有 COM 接口的 D 对象都可以被外部 COM 客户端使用。
按定义,COM 接口从 std.c.windows.com.IUnknown 接口派生。COM 接口与普通 D 接口的不同之处在于:
- 它从 std.c.windows.com.IUnknown 接口派生。
- 它不能成为 DeleteExpression 的参数。
- 引用不能向上转型为封装的类对象(the enclosing class object),也不能向下转型为从它派生的接口。为了达到这个目的,必须按照标准的 COM 风格为接口实现一个合适的 QueryInterface() 方法。