4.4 函数的高级特性
到目前为止,我已经介绍了与函数相关的核心功能,但还有一些高级功能值得探索。不过,如果你确实是软件开发方面的新手,你可能会想暂时跳过本章的其余部分,转到下一章。
4.4.1 Object Pascal 的调用约定
每当你的代码需要调用函数时,双方需要就参数从调用者传递给被调用者的实际方式达成一致,这就是所谓的调用约定。一般来说,函数调用是通过堆栈内存区域传递参数(并期望返回值)。不过,参数和返回值在堆栈中的顺序会因编程语言和平台的不同而改变,大多数编程语言都能使用多种不同的调用约定。
很久以前,32 位版本的 Delphi 引入了一种新的参数传递方法,即 “fastcall”: 只要有可能,最多可以在 CPU 寄存器中传递三个参数,从而使函数调用速度大大提高。Object Pascal 默认使用这种快速调用约定,但也可以通过使用register关键字来请求。
Fastcall 是默认的调用约定,使用这个调用约定的函数与外部库不兼容,如 Win32 中的 Windows API 函数。Win32 API 的函数必须使用 stdcall(标准调用)调用约定来声明,它是 Win16 API 的原始 pascal 调用约定和 C 语言的 cdecl 调用约定的混合体。Object Pascal 支持所有这些调用约定,但除非需要调用不同语言编写的库,如系统库,否则很少会使用与默认约定不同的调用约定。
需要摒弃默认快速调用约定的一个典型情况是需要调用平台的本地 API,根据操作系统的不同,需要使用不同的调用约定。即使是 Win64 也使用与 Win32 不同的模式,因此 Object Pascal 支持许多不同的选项,这里不值得详述。移动操作系统倾向于公开类,而不是本地函数,但即使在这些情况下,也必须考虑尊重特定调用约定的问题。
4.4.2 过程类型
Object Pascal 的另一个特点是存在过程类型。这实际上是一个高级的语言主题,只有少数程序员会使用。然而,由于我们将在后面的章节中将讨论相关主题(具体来说是方法指针,这是环境用于定义事件处理程序以及匿名方法的一种技术),在这里简要介绍一下是值得的。
在 Object Pascal 中(但不在更传统的 Pascal 语言中),存在过程类型的概念(与 C 语言的函数指针概念类似——这是 C# 和 Java 等语言已经放弃的功能,因为它与全局函数和指针相关)。过程类型的声明指出参数列表,在函数的情况下还包括返回类型。例如,您可以使用以下代码声明一个新的过程类型,其中包含一个按引用传递的 Integer 参数:
type
TIntProc = procedure(var Num: Integer);
这个过程类型与具有完全相同参数的任何例程兼容(或者使用 C 的术语来说,具有相同的函数签名)。以下是一个兼容例程的示例:
procedure DoubleIt(var Value: Integer);
begin
Value := Value * 2;
end;
过程类型可以用于两种不同的目的:您可以声明过程类型的变量,或将过程类型(即函数指针)作为参数传递给另一个例程。鉴于前述类型和过程声明,您可以编写以下代码:
var
IP: TIntProc;
X: Integer;
begin
IP := DoubleIt;
X := 5;
IP(X);
end;
这段代码与以下较短版本具有相同的效果:
var
X: Integer;
begin
X := 5;
DoubleIt(X);
end;
第一个版本显然更复杂,那么我们为什么要使用它,什么时候使用它呢?在某些情况下,能够延后决定实际调用哪个函数的能力会非常强大。我们可以建立一个复杂的示例来展示这种方法。不过,我更愿意让大家探索一个相当简单的示例,名为 ProcType。
这个示例基于两个过程。一个过程用于将参数值加倍,就像我已经展示过的那样。第二个过程用于将参数值加三倍,因此被命名为 TripleIt:
procedure TripleIt(var Value: Integer);
begin
Value := Value * 3;
end;
我们不直接调用这些函数,而是将其中一个或另一个保存在程序类型变量中。当用户选择复选框时,该变量就会被修改,而当用户点击按钮时,当前过程就会以这种通用方式被调用。程序使用了两个初始化的全局变量(要调用的过程和当前值),因此这些值会随着时间的推移而保留。这是完整的代码,除去上面已经显示的实际过程的定义:
var
IntProc: TIntProc = DoubleIt;
Value: Integer = 1;
procedure TForm1.CheckBox1Change(Sender: TObject);
begin
if CheckBox1.IsChecked then
IntProc := TripleIt
else
IntProc := DoubleIt;
end;
procedure TForm1.Button1Click(Sender: TObject);
begin
IntProc(Value);
Show(Value.ToString);
end;
当用户更改复选框状态时,随后的所有按钮点击都将调用活动函数。因此,如果您按两次按钮,更改选择,然后再按两次按钮,您将先将当前值加倍两次,然后将其加倍两次,生成以下输出:
2
4
12
36
使用过程类型的另一个实际示例是当您需要将函数传递给像 Windows 这样的操作系统时(通常称为“回调函数”)。正如本节开头提到的,除了过程类型,Object Pascal 开发人员还使用方法指针(在第 10 章中介绍)和匿名方法(在第 15 章中介绍)。
注解:在面向对象的机制中,获得后期绑定的函数调用(即运行时可以改变的函数调用)的最常见的方法是使用虚方法。虚方法在 Object Pascal 中非常常见,而过程类型却很少使用。然而,技术基础在某种程度上是相似的。虚函数和多态性将在第 8 章中讨论。
4.4.3 外部函数声明
外部声明是系统编程的另一个重要元素。外部声明最初用于将代码链接到用汇编语言编写的外部函数,在 Windows 编程中,外部声明已成为调用 DLL(动态链接库)函数的常用方法。外部函数声明意味着可以调用编译器或链接器无法完全使用的函数,但需要加载外部动态链接库并调用其中的一个函数。
注解:每当在您的 Object Pascal 代码中调用某个平台的 API 时,您失去了在任何其他平台上重新编译应用程序的能力,除非调用被平台特定的 $IFDEF 编译指令所包围。
这就是您可以从 Delphi 应用程序中调用 Windows API 函数的方式。如果打开 Winapi.Windows 单元,您将找到许多函数声明和定义,如下所示:
// 前置声明
function GetUserName(lpBuffer: LPWSTR;
var nSize: DWORD): BOOL; stdcall;
// 外部声明(而不是实际代码)
function GetUserName; external advapi32
name 'GetUserNameW';
由于 Windows 单元和许多其他系统单元中已经列出了这些声明,因此您很少需要编写类似刚才说明的声明。需要编写外部声明代码的唯一原因是调用自定义 DLL 中的函数,或调用平台 API 中未翻译的 Windows 函数。
此声明意味着函数 GetUserName 的代码将以 GetUserNameW 的名称存储在 advapi32 动态库中(advapi32 是与 DLL 全名 "advapi32.dll "相关联的常量),因为此 API 函数既有 ASCII 版本,也有 WideString 版本。在外部声明中,我们可以指定我们的函数引用一个 DLL 函数,而该 DLL 函数最初的名称是不同的。
DLL 函数的延迟加载
在 Windows 操作系统中,有两种方法可以调用 Windows SDK(或任何其他 DLL)的 API 函数:一种是让应用程序加载器解决所有外部函数的引用问题,另一种是编写特定代码来查找函数并在函数可用时执行它。
前一种代码更容易编写(正如我们在上一节中所看到的):因为你所需要的只是外部函数声明。但是,如果你想调用的函数库或哪怕只有一个函数不可用,你的程序将无法在不提供该函数的操作系统版本上启动。
动态加载允许更大的灵活性,但需要手动加载库,使用 GetProcAddress API 查找要调用的函数,并在将指针转换为适当类型后调用该函数。这种代码相当繁琐,而且容易出错。
因此,Object Pascal 编译器和链接器专门支持 Windows 操作系统中的一项功能,而且一些 C++ 编译器已经使用了这项功能,即在调用函数之前延迟加载函数。这种声明的目的不是为了避免 DLL 的隐式加载(无论如何都要加载),而是为了允许在 DLL 中延迟绑定特定函数。
基本上,你编写代码的方式与 DLL 函数的经典执行方式非常相似,但函数地址是在首次调用函数时解析的,而不是在加载时。这意味着,如果函数不可用,就会出现运行时异常,即 EExternalException。不过,一般情况下,您可以验证操作系统的当前版本或您要调用的特定库的版本,并提前决定是否要进行调用。
注解:如果你想要一个比异常更具体、更容易在全局级别上处理的方式,你可以挂钩延迟加载调用的错误机制,正如 Allen Bauer 在他的博客文章中解释的那样:https://blog.therealoracleatdelphi.com/2009/08/exceptional-procrastination_29.html
从 Object Pascal 语言的角度来看,唯一的区别在于外部函数的声明,而不是编写:
function MessageBox;
external user32 name 'MessageBoxW';
现在可以编写(同样来自 Windows 单元中的实际示例):
function GetSystemMetricsForDpi(nIndex: Integer; dpi: UINT): Integer;
stdcall; external user32 name 'GetSystemMetricsForDpi' delayed;
在运行时,考虑到该 API 是首次添加到 Windows 10 1607 版本中,您可能希望编写如下代码:
if (TOSVersion.Major >= 10) and (TOSVersion.Build >= 14393) then
NMetric := GetSystemMetricsForDpi(SM_CXBORDER, 96);
这比在旧版本 Windows 上,没有延迟加载的情况下运行相同程序所需的代码要少得多。
另一个相关的观察是,在构建自己的 DLL 并在Object Pascal 中调用它们时,只要对新函数使用延迟加载,就可以使用相同的机制,提供一个可以绑定到同一 DLL 的多个版本的单一可执行文件。