声明/定义语句

声明变量、数组、函数或程序的任何其他命名元素(包括结构和类,这些将在第三章讨论)即构成一个语句。

声明必须包含元素的类型和标识符(参见 声明和定义变量),以及一个可选的初始值,用于完成 初始化。此外,在声明时,可以指定附加的修饰符来改变元素的某些特性。特别是,我们已经知道 staticconst 修饰符,很快还会认识更多。数组需要对维度和元素数量进行附加说明(参见 数组的说明),而函数则需要说明一列参数(更多详细信息,请参见 函数)。

变量声明语句可以总结如下:

[modifiers] identifier type
  [= initialization expressions] ;

对于数组,它是这个样子的:

[modifiers] identifier type [ [size_1]ᵒᵖᵗ ] [ [size_N] ]ᵒᵖᵗ(3)
  [ = { initialization_list } ]ᵒᵖᵗ ;

主要的区别是必须至少有一对方括号(可以在方括号指定数组大小,也可以不指定;基于此,我们得到一个固定或动态分布的数组)。最多允许 4 对方括号(支持的最大测量数是 4)。

在多数情况下,声明可以同时充当定义,也就是说,它为元素保留内存,决定它的行为,并使其能够在程序中使用。具体来说,变量或数组的声明也是一个定义。从这个角度来看,声明语句可以被称为定义语句,但这个做法并不十分常见。

我们掌握的函数的基本知识,足以让我们推断其定义的形式:

type identifier ( [list_of_arguments] )
{
  [statements]
}

类型、标识符和自变量列表构成了函数头。

请注意,这是一个定义,因为此说明包含了函数的外部属性(接口)和定义其内部本质(实现)的语句。后者是带有一对花括号的代码块,紧跟在函数头之后。你可能会认为,这是我们在 上一节中提到的复合语句的例子。在这种情况下,术语上的同义反复是必不可少的,因为它是完全合理的:复合语句是函数定义语句的一部分。

稍后,我们将学习为什么以及如何将接口说明与实现分离,从而能够直接实现 函数声明 ,而无需定义。我们还将演示 声明和定义之间的区别 (以类为例)。

声明语句通过在语句所在代码块的上下文中声明新元素的名称,使之可用(参见 变量的上下文、作用域和生存期)。回顾一下,代码块构成了对象(变量、数组)的局部作用域。在本书的第一章,我们在介绍问候语函数时遇到了这种情况。

除了局部作用域,还始终存在一个全局作用域,在全局作用域中,您还可以使用声明语句来创建可以从程序中的任何位置访问的元素。

如果在声明语句中没有出现 static 修饰符,但在某个局部代码块中出现,那么相应的元素在语句执行的时候被创建和初始化(严格地说,为了提高效率,函数内部所有局部变量的内存是在进入函数时立即分配的,但那时它们还没有形成)。

例如,OnStart 函数开头的变量 i 的以下声明可确保一旦该函数获得控制权(即它作为脚本的主函数,被终端调用),即用指定初始值 (0) 创建这样一个变量。

void OnStart()
{
   int i = 0;
   Print(i);
   
   // error: 'j' - undeclared identifier
   // Print(j); 
   int j = 1;
}

由于第一个语句中的声明,变量 i 在函数的后续行中是已知的且可用的,特别是在第二行中包含 Print 函数调用,此函数在日志中显示变量的内容。

在函数最后一行中描述的变量 j 将在函数结束之前创建(虽然这没有什么意义,但便于理解)。因此,该变量在该函数的所有早期字符串中都不可知。试图使用带注释的 Print 调用将 j 输出到日志将导致“未声明的标识符”编译错误。

以这种方式声明的元素(在代码块内部,并且没有使用 static 修饰符)被称为自动元素,因为程序本身在进入代码块时为自动元素分配内存,并在退出代码块时(在我们的例子中,是在退出函数之后)销毁自动元素。因此,发生这种情况的内存区域称为栈(“后进先出”)。

自动元素是按照声明语句的执行顺序创建的(先是 i,然后是 j)。销毁则以相反顺序执行(先是 j,然后是 i)。

如果一个变量在没有初始化的情况下被声明,并且在没有先向其中写入一个有意义的值的情况下就开始在后续语句中使用(例如,放置在 = 符号右侧),编译器会发出一个警告:“可能使用了未初始化的变量”。

void OnStart()
{
   int ip;
   i = p// warning: possible use of uninitialized variable 'p'
}

如果声明语句具有 static 修饰符,则对应元素只在语句第一次执行时被创建一次,并保留在内存中,无论是否退出或后续多次进出同一代码块。所有这些静态成员只有在程序卸载时才会被移除。

尽管生存期延长了,但这类变量的作用域仍然局限于定义它们的局部上下文,并且只能从后续语句(位于代码下方)访问。

相比之下,全局上下文中的声明语句会在程序加载之后(在调用任何标准的 start 函数之前,比如用于脚本的 OnStart 函数),按照它们在源代码中出现的顺序创建元素。当程序卸载时,全局对象以相反的顺序被移除。

为了演示上述内容,让我们创建一个更“巧妙”的例子 (StmtDeclaration.mq5)。回顾一下在第一章中学习的技能,除了 OnStart 之外,我们将编写一个简单的 Init 函数,该函数将用于变量初始化表达式,并将记录一个调用序列。

int Init(const int v)
{
   Print("Init: "v);
   return v;
}

Init 函数接受整数类型 int 的单个参数 v,其值返回给调用代码(return 语句)。

这样便可以将它作为包装器来设置变量的初始值,例如,为两个全局变量设置初始值:

int k = Init(-1);
int m = Init(-2);

通过调用函数并从中返回,传递的自变量的值将进入变量 km。但在 Init 内部,我们另外使用了 Print 输出这个值,因此我们可以跟踪变量的创建方式。

请注意,我们不能在全局变量的初始化中(在其定义的上方)使用 Init 函数。如果我们试图将 k 变量声明移到 Init 声明的上方,会遇到“‘Init’是一个未知标识符”错误。这种限制只适用于全局变量的初始化,因为函数也是全局定义的,编译器会一次性建立一个这类标识符的列表。在任何其他情况下,在代码中定义函数的顺序并不重要,因为编译器首先将它们全部注册在内部列表中,然后从代码块中相互关联它们的调用。特别是,您可以将整个 Init 函数和全局变量 km 的声明移到 OnStart 函数下方 - 这不会有任何问题。

OnStart 函数内部,我们将使用 Init 描述更多变量:局部变量 ij,以及静态变量 n。为简单起见,所有变量都被赋予了唯一的值,以便区分它们。

void OnStart()
{
   Print(k);
   
   int i = Init(1);
   Print(i);
   // error: 'n' - undeclared identifier
   // Print(n);
   static int n = Init(0);
   // error: 'j' - undeclared identifier
   // Print(j);
   int j = Init(2);
   Print(j);
   Print(n);
}

这里的注释解释了“未定义相关变量就尝试调用”这种错误。

运行脚本并获取以下日志:

Init: -1
Init: -2
-1
Init: 1
1
Init: 0
Init: 2
2
0

正如我们所看到的,全局变量在 OnStart 函数被调用之前初始化,并且完全按照它们在代码中出现的顺序初始化。内部变量的创建顺序与其声明语句的编写顺序相同。

如果定义了一个变量,但没有在任何地方使用,编译器将发出“变量‘名称’未使用”警告。这个迹象表明程序员可能犯错了。

从前瞻性的角度来说,通过声明/定义语句,不仅可以将数据元素(变量、数组)或函数引入程序中,还可以将不为我们所知的新的用户定义类型(结构、类、模板、名称空间)引入程序。这种语句只能在全局级别上声明,也就是说,不能在所有函数外部定义。

也不能在函数内部定义一个函数。以下代码将不会被编译:

void OnStart()
{
   int Init(const int v)
   {
      Print("Init: "v);
      return v;
   }
   int i = 0;
}

编译器将生成一个错误:“仅允许对全局、命名空间或类作用域进行函数声明”。