参考:https://www.learncpp.com/
随着程序开始变得越来越长,将所有代码放在主函数中变得越来越难以管理。函数为我们提供了一种将程序拆分为更易于组织、测试和使用的小块的方法。大多数程序使用许多功能。C++标准库附带了许多已经编写的函数供您使用 - 但是,编写自己的函数同样常见。您自己编写的函数称为用户定义函数。
更新我们的定义:函数是旨在执行特定工作的可重用语句序列。
returnType functionName() // This is the function header (tells the compiler about the existence of the function)
{
// This is the function body (tells the compiler what the function does)
}
第一行非正式地称为函数标头,它告诉编译器函数的存在、函数的名称以及我们将在以后的课程中介绍的其他一些信息(如返回类型和参数类型)。
介于两者之间的大括号和语句称为函数体。这就是确定函数功能的操作的语句将要去的地方。
可以多次调用函数
可以函数调用函数调用函数
不支持嵌套函数
与其他一些编程语言不同,在C++中,函数不能在其他函数中定义。以下程序是不合法的:
#include
int main()
{
void foo() // Illegal: this function is nested inside function main()
{
std::cout << “foo!\n”;
}
foo(); // function call to foo()
return 0;
}
当你编写一个用户定义的函数时,你可以确定你的函数是否会将一个值返回给调用方。若要将值返回给调用方,需要做两件事。
1.函数必须指示将返回哪种类型的值,该类型是在函数名称之前定义的类型。
2.在将返回值的函数中,我们使用 return 语句来指示返回给调用方的特定值。从函数返回的特定值称为返回值。执行 return 语句时,函数立即退出,返回值从函数复制回调用方。此过程称为按值返回。
如果程序正常运行,函数 main 应返回该值 0 。非零状态代码通常用于指示故障(虽然这在大多数操作系统上工作正常,但严格来说,它不能保证是可移植的)。
C++标准仅定义了 3 个状态代码的含义:0、EXIT_SUCCESS 和 EXIT_FAILURE。0 和 EXIT_SUCCESS 都表示程序已成功执行。EXIT_FAILURE表示程序未成功执行。
#include // for EXIT_SUCCESS and EXIT_FAILURE
int main()
{
return EXIT_SUCCESS;
}
C++不允许显式调用 main 该函数。
返回值的函数称为值返回函数。如果返回类型不是 void ,则函数为值返回。值返回函数必须返回该类型的值(使用 return 语句),否则将导致未定义的行为。
如果未提供 return 语句,函数 main 将隐式返回 0
值返回函数必须通过 return 语句返回值的规则的唯一例外是函数 main()
值返回函数每次调用时只能将单个值返回给调用方。有多种方法可以解决函数只能返回单个值的限制,我们将在以后的课程中介绍。
不要违反了优秀编程的核心原则之一:不要重复自己
模块化编程的本质:编写函数的能力,测试它,确保它工作,然后知道我们可以根据需要多次重用它,它将继续工作(只要我们不修改函数——此时我们将不得不重新测试它)。
总结:函数提供了一种最小化程序中冗余的方法。
函数不需要将值返回给调用方。为了告诉编译器函数不返回值,请使用 void 的返回类型。例如:
void函数中不需要返回语句
某些语句需要提供值,而其他语句则不需要。当我们调用一个函数本身时,我们调用一个函数是为了它的行为,而不是它的返回值。在这种情况下,我们可以调用非值返回函数,也可以调用值返回函数并忽略返回值。
当我们在需要值的上下文中调用函数时(例如 std::cout ),必须提供一个值。在这种情况下,我们只能调用值返回函数。
尝试从非值返回函数返回值将导致编译错误:
void printHi() // This function is non-value returning
{
std::cout << "In printHi()" << '\n';
return 5; // compile error: we're trying to return a value
}
在上一课中,我们了解到可以让函数将值返回给函数的调用方。我们用它来创建一个模块化的getValueFromUser函数,我们在这个程序中使用了它:
函数parameter是函数标头中使用的变量。函数参数的工作方式与函数内部定义的变量几乎相同,但有一个区别:它们使用函数调用方提供的值进行初始化。
// This function takes no parameters
// It does not rely on the caller for anything
void doPrint()
{
std::cout << "In doPrint()\n";
}
// This function takes one integer parameter named x
// The caller will supply the value of x
void printValue(int x)
{
std::cout << x << '\n';
}
// This function has two integer parameters, one named x, and one named y
// The caller will supply the value of both x and y
int add(int x, int y)
{
return x + y;
}
argument参数是在进行函数调用时从调用方传递给函数的值。请注意,多个参数也用逗号分隔。
调用函数时,函数的所有parameters 都创建为变量,并将每个arguments 的值复制到匹配参数中。此过程称为按值传递。
请注意,参数的数量通常必须与函数参数的数量匹配,否则编译器将抛出错误。传递给函数的参数可以是任何有效的表达式(因为参数本质上只是参数的初始值设定项,而初始值设定项可以是任何有效的表达式)。
#include
int getValueFromUser()
{
std::cout << "Enter an integer: ";
int input{};
std::cin >> input;
return input;
}
void printDouble(int value) // This function now has an integer parameter
{
std::cout << value << " doubled is: " << value * 2 << '\n';
}
int main()
{
int num { getValueFromUser() };
printDouble(num);
return 0;
}
通过使用参数和返回值,我们可以创建将数据作为输入的函数,用它进行一些计算,并将值返回给调用方。
在某些情况下,您会遇到具有函数主体中未使用的参数的函数。这些参数称为未引用参数。。
就像未使用的局部变量一样,编译器可能会警告变量 count 已定义但未使用。
在函数定义中,函数参数的名称是可选的。因此,如果函数参数需要存在但未在函数主体中使用,则只需省略名称即可。没有名称的参数称为未命名参数:
void doSomething(int) // ok: unnamed parameter will not generate warning
{
}
局部变量:
在函数体内定义的变量称为局部变量(与全局变量相反,我们将在以后的章节中讨论)
函数参数通常也被认为是局部变量
局部变量生存期:
输入函数时创建和初始化函数参数,并在定义点创建和初始化函数体内的变量。
自然的后续问题是,“那么实例化变量何时被破坏?局部变量在定义它的花括号集末尾(或者对于函数参数,在函数末尾)以与创建顺序相反的顺序销毁。
请注意,变量的创建和销毁发生在程序运行时(称为运行时),而不是编译时。因此,生存期是一个运行时属性。
局部变量范围:
局部变量的作用域从变量定义点开始,并在定义它的花括号集的末尾停止(对于函数参数,在函数的末尾)。这可确保变量不能在定义点之前使用(即使编译器选择在此之前创建它们)。在一个函数中定义的局部变量也不在调用的其他函数的作用域中。
“Out of scope” vs “going out of scope”
out of scope:标识符在代码中无法访问的任何位置
going out of scope:通常应用于对象而不是标识符。
lifespan是一个运行时属性,而 scope 是一个编译时属性
用于函数体中声明的函数参数或变量的名称仅在声明它们的函数中可见。这意味着可以命名函数中的局部变量,而无需考虑其他函数中的变量名称。这有助于保持函数独立。
在现代C++中,最佳实践是函数体内的局部变量应定义为接近其首次使用为合理:
新程序员经常问:“我们不能把所有代码都放在主函数中吗?对于简单的程序,您绝对可以。但是,函数提供了许多好处,使它们在很长或复杂性的程序中非常有用。
有效使用函数:
以下是编写函数的一些基本准则:
通常,在学习C++时,您将编写许多涉及 3 个子任务的程序:
解决程序中的编译错误时,请始终解决首先生成的第一个错误,然后再次编译。
以下代码不会编译:
#include
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n';
return 0;
}
int add(int x, int y)
{
return x + y;
}
旧版本的Visual Studio会产生一个额外的错误:
add.cpp(9) : error C2365: ‘add’; : redefinition; previous definition was ‘formerly unknown identifier’
为了解决这个问题,有两种常见的方法可以解决此问题。
为了函数编写前向声明,我们使用函数声明语句(也称为函数原型)。函数声明由函数的返回类型、名称和参数类型组成,以分号结尾。可以选择包含参数的名称。函数体不包含在声明中。
现在,这是我们没有编译的原始程序,使用函数声明作为函数添加的前向声明:
#include
int add(int x, int y); // forward declaration of add() (using a function declaration)
int main()
{
std::cout << "The sum of 3 and 4 is: " << add(3, 4) << '\n'; // this works because we forward declared add() above
return 0;
}
int add(int x, int y) // even though the body of add() isn't defined until here
{
return x + y;
}
可以通过复制/粘贴函数的标头并添加分号来轻松创建函数声明。
值得注意的是,函数声明不需要指定参数的名称(因为它们不被视为函数声明的一部分)。
int add(int, int); // valid function declaration
为什么要转发声明?
1.大多数情况下,前向声明用于告知编译器存在已在不同代码文件中定义的某个函数。在这种情况下无法重新排序,因为调用方和被调用方位于完全不同的文件中
2.这允许我们以任何顺序定义函数,以最大化组织(例如,通过将相关函数聚类在一起)或读者理解
新程序员经常想知道如果他们转发声明一个函数但不定义它会发生什么。
答案是:视情况而定。如果进行了前向声明,但从未调用该函数,则程序将编译并正常运行。但是,如果进行了前向声明并调用了函数,但程序从未定义该函数,则程序将编译正常,但链接器将抱怨它无法解析函数调用。
前向声明最常与函数一起使用。但是,前向声明也可以与C++中的其他标识符一起使用,例如变量和类型。
声明告知编译器标识符及其关联的类型信息的存在。
定义是实际实现(对于函数和类型)或实例化(对于变量)标识符的声明。
在C++中,所有定义都是声明。因此 int x;
,它既是一个定义,也是一个声明。当编译器遇到标识符时,它将检查以确保该标识符的使用有效(例如,标识符是否在范围内,是否以语法上有效的方式使用,等等)
一个定义规则(或简称 ODR)是 C++ 中众所周知的规则。
ODR 由三部分组成:
1.在给定文件中,函数、变量、类型或模板只能有一个定义。
2.在给定的程序中,变量或普通函数只能有一个定义。之所以进行这种区分,是因为程序可以有多个文件(我们将在下一课中介绍这一点)。
3.允许类型、模板、内联函数和内联变量在不同的文件中具有相同的定义。我们还没有介绍这些东西中的大多数是什么,所以现在不要担心这个 - 我们会在相关时重新提出来。
违反 ODR 的第 1 部分将导致编译器发出重定义错误。
违反 ODR 第 2 部分将导致链接器发出重定义错误或导致未定义的行为。
违反 ODR 第 3 部分将导致未定义的行为。
ps:共享标识符但具有不同参数的函数被视为不同的函数
随着程序变大,通常将它们拆分为多个文件以用于组织或可重用性目的。使用 IDE 的一个优点是,它们使处理多个文件变得更加容易。您已经知道如何创建和编译单文件项目。向现有项目添加新文件非常容易。
在 Visual Studio 中,右键单击“解决方案资源管理器”窗口中的“源文件”文件夹(或项目名称),然后选择“>添加新项…”。
编译器单独编译每个文件。它不知道其他代码文件的内容,也不记得从以前编译的代码文件中看到的任何内容。
因此,即使编译器可能已经看到函数的定义添加之前(如果它编译了xxx.cpp首先),它也不记得了。
这种有限的可见性和短暂的记忆是有意为之的,原因有两个:
1.当我们更改源文件时,只需要重新编译该源文件。
2.些情况下,它允许在一个文件中定义函数或变量,而不会与另一个文件中相同名称的不同用法发生冲突。
解决方案是使用前向声明:
main.cpp 使用前向声明:
add.cpp 实现
现在,当编译器编译main.cpp时,它将知道添加的标识符是什么并得到满足。链接器会将函数调用连接到 add.cpp 中的函数 add.cpp 中的函数添加的定义。
使用这种方法,我们可以让文件访问另一个文件中的函数。
key insight:
C++的设计使得每个源文件都可以独立编译,而不知道其他文件中的内容。因此,实际编译文件的顺序不应相关。
提醒:每当创建新的代码 (.cpp) 文件时,都需要将其添加到项目中,以便对其进行编译。
如果以编译器或链接器无法区分的方式将两个相同的标识符引入同一程序,则编译器或链接器将产生错误。此错误通常称为命名冲突(或命名冲突)。
如果将冲突标识符引入同一文件,则结果将是编译器错误。如果将冲突标识符引入属于同一程序的单独文件中,则结果将是链接器错误。
大多数命名冲突发生在两种情况下:
1.两个(或多个)同名函数(或全局变量)被引入到属于同一程序的单独文件中。这将导致链接器错误,如上所示。
2.两个(或多个)同名函数(或全局变量)被引入到同一个文件中。这将导致编译器错误。
随着程序变得越来越大并使用更多的标识符,引入命名冲突的几率会大大增加。好消息是,C++提供了许多避免命名冲突的机制。局部作用域就是这样一种机制,它可以防止函数内部定义的局部变量相互冲突。但本地作用域不适用于函数名称。那么我们如何防止函数名称相互冲突呢?
命名空间是一个区域,允许您在其中声明名称以消除歧义。命名空间为其内部声明的名称提供了一个作用域(称为命名空间作用域),这仅意味着在命名空间内声明的任何名称都不会被误认为是其他作用域中的相同名称。
在命名空间中,所有名称都必须是唯一的,否则将导致命名冲突。
命名空间通常用于对大型项目中的相关标识符进行分组,以帮助确保它们不会无意中与其他标识符发生冲突。例如,如果将所有数学函数放在名为 math 的命名空间中,则数学函数不会与 math 命名空间外同名的函数发生冲突。
在 C++ 中,未在类、函数或命名空间中定义的任何名称都被视为隐式定义的命名空间(称为全局命名空间(有时也称为全局范围)的一部分。
只有声明和定义语句可以出现在全局命名空间中。这意味着我们可以在全局命名空间中定义变量,尽管通常应该避免这种情况
最初设计C++时,C++标准库中的所有标识符(包括 std::cin 和 std::cout)都可以在没有 std:: 前缀的情况下使用(它们是全局命名空间的一部分)。但是,这意味着标准库中的任何标识符都可能与您为自己的标识符(也在全局命名空间中定义)选择的任何名称发生冲突。当您从标准库中 #included 新文件时,正常工作的代码可能会突然出现命名冲突。或者更糟糕的是,在一个版本的C++下编译的程序可能无法在未来版本的C++下编译,因为引入标准库的新标识符可能与已经编写的代码存在命名冲突。因此,C++标准库中的所有功能都移动到名为“std”(标准缩写)的命名空间中。
std::cout
的名字并不是真正的 std::cout
。它实际上只是 cout,std 是标识符 cout 所属的命名空间的名称。由于 cout 是在 std 命名空间中定义的,因此名称 cout 不会与我们在全局命名空间中创建的任何名为 cout 的对象或函数冲突。
同样,当访问在命名空间中定义的标识符(例如 std::cout)时,您需要告诉编译器我们正在寻找在命名空间 (std) 中定义的标识符。
使用在命名空间(如 std 命名空间)内定义的标识符时,必须告知编译器标识符位于命名空间内。
1.显式命名空间限定符. :: 符号是称为范围解析运算符的运算符,如果未提供 :: 符号左侧的标识符,则假定为全局命名空间。
2.使用命名空间 std 。访问命名空间内标识符的另一种方法是使用 using 指令语句。
#include
using namespace std; // this is a using directive that allows us to access names in the std namespace with no namespace prefix
int main()
{
cout << "Hello world!";
return 0;
}
using 指令允许我们在不使用命名空间前缀的情况下访问命名空间中的名称。
最佳实践:使用显式命名空间前缀访问命名空间中定义的标识符。许多文本、教程,甚至一些 IDE 都推荐或使用程序顶部的 using 指令。但是,以这种方式使用,这是一种不好的做法,非常不鼓励。
在编译之前,每个代码 (.cpp) 文件都会经历一个预处理阶段。在此阶段,称为预处理器的程序对代码文件的文本进行各种更改。预处理器实际上不会以任何方式修改原始代码文件,相反,预处理器所做的所有更改都临时发生在内存中或使用临时文件。
预处理器所做的大部分操作都相当无趣。例如,它去除注释,并确保每个代码文件都以换行符结尾。但是,预处理器确实有一个非常重要的角色:它是处理 #include 指令的
当预处理器处理完代码文件时,结果称为translation unit/翻译单元。此翻译单元是编译器随后编译的内容。
当预处理器运行时,它会扫描代码文件(从上到下),查找预处理器指令。预处理器指令(通常简称为指令)是以 # 符号开头并以换行符(不是分号)结尾的指令。这些指令告诉预处理器执行某些文本操作任务。请注意,预处理器不理解C++语法 - 相反,指令有自己的语法
#Include
预处理器会将 #include 指令替换为包含文件的内容。然后对包含的内容进行预处理(这可能会导致递归预处理其他 #includes),然后对文件的其余部分进行预处理。
#include
int main()
{
std::cout << "Hello, world!\n";
return 0;
}
当预处理器在此程序上运行时,预处理器将替换为 #include 名为“iostream”的文件的内容,然后预处理包含的内容和文件的其余部分。
一旦预处理器处理完代码文件以及所有 #included 内容,结果称为翻译单元。翻译单元是发送到编译器进行编译的内容。
翻译单元既包含代码文件中已处理的代码,也包含所有 #included 文件中已处理的代码。
类似函数的宏的作用类似于函数,并且具有类似的目的。它们的使用通常被认为是不安全的,几乎任何它们能做的都可以通过正常函数来完成。
可以通过以下两种方式之一定义类似对象的宏:
#define identifier
#define identifier substitution_text
具有替换文本的类似对象的宏:
当预处理器遇到此指令时,标识符的任何进一步出现都将替换为 substitution_text。标识符传统上以全大写字母键入,使用下划线表示空格。
#include
#define MY_NAME "Alex"
int main()
{
std::cout << "My name is: " << MY_NAME << '\n';
return 0;
}
预处理器将上述内容转换为以下内容:
// The contents of iostream are inserted here
int main()
{
std::cout << "My name is: " << "Alex" << '\n';
return 0;
}
我们建议完全避免使用这些类型的宏,因为有更好的方法来执行此类操作。
例如:
#define USE_YEN
此形式的宏的工作方式与您预期的方式相同:标识符的任何进一步出现都将被删除并替换为任何内容!
这可能看起来很无用,并且对于进行文本替换也毫无用处。但是,这不是这种形式的指令通常用于的目的。这种形式的宏通常被认为是可以接受的。
条件编译预处理器指令允许您指定在什么条件下会编译或不编译某些东西。有相当多不同的条件编译指令,但我们只介绍迄今为止使用最多的三个:#ifdef、#ifndef 和 #endif。
#ifdef 预处理器指令允许预处理器检查标识符以前是否已 #defined。如果是这样,则编译 #ifdef 和匹配 #endif 之间的代码。否则,将忽略代码。
请考虑以下程序:
#include
#define PRINT_JOE
int main()
{
#ifdef PRINT_JOE
std::cout << "Joe\n"; // will be compiled since PRINT_JOE is defined
#endif
#ifdef PRINT_BOB
std::cout << "Bob\n"; // will be excluded since PRINT_BOB is not defined
#endif
return 0;
}
#ifndef 与 #ifdef 相反,因为它允许您检查标识符是否尚未 #defined
代替 和 #ifdef PRINT_BOB #ifndef PRINT_BOB ,您还将看到 #if defined(PRINT_BOB) 和 #if !defined(PRINT_BOB) 。它们执行相同的操作,但使用稍微C++样式的语法。
条件编译的一个更常见的用法是使用 #if 0 从编译中排除代码块(就好像它在注释块内一样):
#include
int main()
{
std::cout << "Joe\n";
#if 0 // Don't compile anything starting here
std::cout << "Bob\n";
std::cout << "Steve\n";
#endif // until this point
return 0;
}
要暂时重新启用已包装在 #if 0 中的代码,可以将 更改为 #if 0 #if 1 :
既然我们将PRINT_JOE定义为nothing,为什么预处理器没有用nothing替换 #ifdef PRINT_JOE 中的PRINT_JOE?
宏只会导致普通代码的文本替换。其他预处理器命令将被忽略。因此,#ifdef PRINT_JOE PRINT_JOE被单独留下。
#define FOO 9 // Here's a macro substitution
#ifdef FOO // This FOO does not get replaced because it’s part of another preprocessor directive
std::cout << FOO << '\n'; // This FOO gets replaced with 9 because it's part of the normal code
#endif
#defines 的范围:
指令在编译之前逐个文件从上到下解析。
请考虑以下程序:
#include
void foo()
{
#define MY_NAME "Alex"
}
int main()
{
std::cout << "My name is: " << MY_NAME << '\n';
return 0;
}
即使看起来“Alex”是在函数 foo 中定义的 #define MY_NAME 预处理器也不会注意到,因为它不理解函数等C++概念。因此,该程序的行为与在函数 foo 之前或之后定义 #define MY_NAME“Alex”的行为相同。为了便于阅读,您通常需要在函数之外 #define 标识符。
预处理器完成后,将丢弃该文件中所有定义的标识符。这意味着指令仅从定义点到定义它们的文件的末尾有效。在一个代码文件中定义的指令对同一项目中的其他代码文件没有影响。
随着程序变大(并使用更多文件),必须转发声明要使用的每个函数(在不同文件中定义)变得越来越乏味。如果您可以将所有转发声明放在一个地方,然后在需要时导入它们,那不是很好吗?
C++代码文件(扩展名为.cpp)并不是C++程序中常见的唯一文件。另一种类型的文件称为头文件。头文件通常具有 .h 扩展名,但您偶尔会看到它们带有 .hpp 扩展名或根本没有扩展名。头文件的主要用途是将声明传播到代码 (.cpp) 文件。
key sight:
头文件允许我们将声明放在一个位置,然后将它们导入到我们需要它们的任何位置。这可以节省多文件程序中的大量输入。
#include
int main()
{
std::cout << "Hello, world!";
return 0;
}
该程序使用 std::cout 将“Hello, world!”打印到控制台。但是,该程序从未提供 std::cout 的定义或声明,那么编译器如何知道 std::cout 是什么?
答案是 std::cout 已在“iostream”头文件中向前声明。当我们 #include ,我们请求预处理器将所有内容(包括 std::cout 的前向声明)从名为“iostream”的文件复制到执行 #include 的文件中。
编写自己的头文件:参考:https://www.learncpp.com/cpp-tutorial/header-files/ 将头文件添加到项目的工作方式类似于添加源文件
头文件通常与代码文件配对,头文件为相应的代码文件提供前向声明。由于我们的头文件将包含 add.cpp 中定义的函数的前向声明,因此我们将新的头文件 add.h 称为 add.h。
最佳实践:
如果头文件与代码文件配对(例如 add.h 和 add.cpp),它们都应该具有相同的基本名称 (add)。
为了在main.cpp中使用此头文件,我们必须 #include 它(使用引号,而不是尖括号)。
#include "add.h" // Insert contents of add.h at this point. Note use of double quotes here.
add.cpp:也要添加 :#include “add.h”
当预处理器处理该 #include “add.h” 行时,它会将 add.h 的内容复制到当前文件中。因为我们的 add.h 包含函数 add() 的前向声明,所以该前向声明将被复制到 main.cpp 中。最终结果是一个功能上与我们在 main.cpp 顶部手动添加前向声明的程序相同的程序。
现在,您应该避免将函数或变量定义放在头文件中。在头文件包含在多个源文件中的情况下,这样做通常会导致违反单定义规则 (ODR)
最佳实践:
在C++中,最佳做法是代码文件 #include 其配对头文件(如果存在)
这允许编译器在编译时而不是链接时捕获某些类型的错误。
尽管预处理器很乐意这样做,但您通常不应 #include .cpp文件。这些应添加到您的项目中并进行编译。
造成这种情况的原因有很多:
如果收到编译器错误,指示找不到 add.h,请确保该文件确实名为 add.h。根据您创建和命名它的方式,该文件可能被命名为添加(无扩展名)或add.h.txt或add.hpp。还要确保它与其余代码文件位于同一目录中。
如果收到有关未定义函数添加的链接器错误,请确保已在项目中包含 add.cpp,以便函数添加的定义可以链接到程序中。
您可能很好奇为什么我们使用尖括号来表示 ,而对 使用 iostream add.h 双引号。具有相同文件名的头文件可能存在于多个目录中。我们使用尖括号与双引号有助于为预处理器提供关于它应该在哪里查找头文件的线索。
当我们使用尖括号时,我们告诉预处理器这是一个不是我们自己编写的头文件。预处理器将仅在 指定的 include directories 目录中搜索标头。它们 include directories 配置为项目/IDE 设置/编译器设置的一部分,通常默认为包含编译器和/或操作系统附带的头文件的目录。预处理器不会在项目的源代码目录中搜索头文件。
当我们使用双引号时,我们告诉预处理器这是我们编写的头文件。预处理器将首先在当前目录中搜索头文件。如果在那里找不到匹配的标头,它将搜索 include directories .
规则:
使用双引号包含已编写或预期在当前目录中找到的头文件。使用尖括号可包含编译器、操作系统或已安装在系统上其他位置的第三方库附带的标头。
另一个常见的问题是“为什么iostream(或任何其他标准库头文件)没有.h扩展名?答案是iostream.h是与iostream不同的头文件!解释需要一堂简短的历史课
首次创建C++时,标准库中的所有文件都以 .h 后缀结尾。生活是一致的,而且是美好的。cout 和 cin 的原始版本在 iostream.h 中声明。当 ANSI 委员会对语言进行标准化时,他们决定将标准库中使用的所有名称移动到 std 命名空间中,以帮助避免与用户声明的标识符发生命名冲突。然而,这带来了一个问题:如果他们将所有名称移动到 std 命名空间中,那么任何旧程序(包括 iostream.h)都不会再工作了!
若要解决此问题,引入了一组缺少 .h 扩展名的新头文件。这些新的头文件声明 std 命名空间中的所有名称。这样,包含的 #include
此外,许多从 C 继承的库在C++中仍然有用,都被赋予了 c 前缀(例如 stdlib.h 变成了 cstdlib)
最佳实践:
如果没有 .h 扩展名的头文件在全局命名空间中声明了名称,请避免使用这些名称,因为它们在其他编译器的全局命名空间中可能不可用。改为首选 std 命名空间中声明的名称。
另一个常见问题涉及如何包含来自其他目录的头文件。
执行此操作的一种(不好的)方法是包含要作为 #include 行的一部分包含的头文件的相对路径。例如:
#include "headers/myHeader.h"
#include "../moreHeaders/myOtherHeader.h"
虽然这将编译(假设文件存在于这些相对目录中),但这种方法的缺点是它要求您在代码中反映目录结构。如果你更新了目录结构,你的代码将不再有效。
更好的方法是告诉您的编译器或 IDE,您在其他位置有一堆头文件,以便在当前目录中找不到它们时它会在那里查找。这通常可以通过在 IDE 项目设置中设置包含路径或搜索目录来完成。
visual studio :在“解决方案资源管理器”中右键单击你的项目,选择“属性”,然后选择“VC++ 目录”选项卡。从这里,您将看到一个名为“包含目录”的行。添加您希望编译器在其中搜索其他标头的目录。
这种方法的好处是,如果您更改了目录结构,则只需更改单个编译器或 IDE 设置,而无需更改每个代码文件。
头文件通常需要位于不同头文件中的声明或定义。因此,头文件通常会 #include 其他头文件。
通常不应依赖以传递方式包含的标头的内容(除非参考文档指示需要这些传递包含)。头文件的实现可能会随时间而变化,或者在不同的系统中有所不同。如果发生这种情况,您的代码可能只在某些系统上编译,或者现在可以编译,但将来不能编译。通过显式包含代码文件内容所需的所有头文件,可以轻松避免这种情况。
最佳实践:
每个文件都应显式 #include 它需要编译的所有头文件。不要依赖从其他标头传递包含的标头。
如果您的头文件编写正确并且 #include 它们所需的一切,则包含顺序应该无关紧要。
如果顺序错误,编译器会提示错误
最佳实践:
为了最大限度地提高编译器标记缺少的包含项的可能性,请按如下方式对 #includes 进行排序:
The headers for each grouping should be sorted alphabetically (unless the documentation for a 3rd party library instructs you to do otherwise).
每个分组的标题应按字母顺序排序(除非第三方库的文档指示您这样做)。
这样,如果您的某个用户定义的标头缺少第三方库或标准库标头的 #include,则更有可能导致编译错误,以便您可以修复它。
始终包括header guards(我们将在下一课中介绍这些内容)。
不要在头文件中定义变量和函数。
为头文件指定与其关联的源文件相同的名称(例如,grades.h 与 grades.cpp 配对)。
每个头文件都应具有特定的作业,并尽可能独立。例如,您可以将与功能 A 相关的所有声明放在 A.h 中,将与功能 B 相关的所有声明放在 B.h 中。这样,如果你以后只关心A,你可以只包括A.h,而得不到任何与B相关的东西。
请注意需要为代码文件中使用的功能显式包含哪些标头。
你编写的每个标头都应该自己编译(它应该 #include 它需要的每个依赖项)。
只 #include 你需要的东西(不要仅仅因为你可以就包括所有内容)。
不要 #include .cpp 文件。
更喜欢在头文件中放置有关某物的作用或如何使用它的文档。它更有可能在那里被看到。描述某些内容如何工作的文档应保留在源文件中。
重复定义问题:对于头文件,很容易出现头文件中的定义被多次包含的情况。当一个头文件 #includes 另一个头文件(这很常见)时,可能会发生这种情况。
Header guards :
可以通过称为标头保护(也称为包含保护)的机制来避免上述问题。
#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE
// your declarations (and certain types of definitions) here
#endif
#included 此标头时,预处理器将检查以前是否定义了SOME_UNIQUE_NAME_HERE。如果这是我们第一次包含标头,则不会定义SOME_UNIQUE_NAME_HERE。因此,它 #defines SOME_UNIQUE_NAME_HERE 并包含文件的内容。如果标头再次包含在同一个文件中,则从第一次包含标头的内容开始就已经定义了SOME_UNIQUE_NAME_HERE,并且标头的内容将被忽略(由于 #ifndef)。
**所有头文件都应该有头保护。**SOME_UNIQUE_NAME_HERE可以是您想要的任何名称,但按照惯例,设置为头文件的完整文件名,键入全部大写,使用空格或标点符号的下划线。例如,square.h 将具有标头保护:
#ifndef SQUARE_H
#define SQUARE_H
int getSquareSides()
{
return 4;
}
#endif
即使是标准的库标头也使用标头保护。
在大型程序中,可能有两个单独的头文件(来自不同目录),最终具有相同的文件名(例如 directoryA\config.h 和 directoryB\config.h)。如果仅将文件名用于包含保护(例如 CONFIG_H),则这两个文件最终可能会使用相同的保护名称。如果发生这种情况,任何包含(直接或间接)两个 config.h 文件的文件将不会收到要包含的包含文件的内容。这可能会导致编译错误。由于存在这种保护名称冲突的可能性,许多开发人员建议在标头保护中使用更复杂/唯一的名称。一些好的建议是命名约定 PROJECT_PATH_FILE_H、FILE_LARGE-RANDOM-NUMBER_H 或 FILE_CREATION-DATE_H。
参考:https://www.learncpp.com/cpp-tutorial/header-guards/
我们将在未来向您展示很多情况,其中需要将非函数定义放在头文件中。例如,C++将允许您创建自己的类型。这些自定义类型通常在头文件中定义,因此类型定义可以传播到需要使用它们的代码文件。如果没有标头保护,代码文件最终可能会包含给定类型定义的多个(相同)副本,编译器会将其标记为错误。
现代编译器支持使用 #pragma 预处理器指令的更简单的替代形式的标头保护:
#pragma once
// your code here
#pragma once 其用途与标头保护相同:避免多次包含头文件。
如果复制头文件以使其存在于文件系统上的多个位置,如果以某种方式包含标头的两个副本,标头保护将成功对相同的标头进行重复数据消除,但 #pragma once 不会
对于大多数项目, #pragma once 工作正常,许多开发人员现在更喜欢它,因为它更容易且不易出错。许多 IDE 还将自动包含在 #pragma once 通过 IDE 生成的新头文件的顶部。
要记住的最重要的事情(也是最难做的事情)是在开始编码之前设计你的程序。在许多方面,编程就像架构。如果您试图在不遵循建筑计划的情况下建造房屋会发生什么?很有可能,除非你非常有才华,否则你最终会得到一个有很多问题的房子:墙壁不直,屋顶漏水等等…同样,如果你在制定一个好的游戏计划之前尝试编程,你可能会发现你的代码有很多问题,你将不得不花很多时间修复本来可以完全避免的问题。
1.定义目标。
为了编写一个成功的程序,您首先需要定义您的目标是什么。理想情况下,您应该能够用一两句话来说明这一点。将其表示为面向用户的结果通常很有用。例如:允许用户组织姓名和关联电话号码的列表。
2.定义需求
虽然定义你的问题可以帮助你确定你想要什么结果,但它仍然很模糊。下一步是考虑需求。
既表示您的解决方案需要遵守的约束(例如预算、时间线、空间、内存等),也表示程序必须展示的功能以满足用户的需求。
例如:应保存电话号码,以便以后可以调用。
3.定义工具、目标和备份计划
当您是经验丰富的程序员时,此时通常会执行许多其他步骤,包括:
4.将难题分解为简单问题
在现实生活中,我们经常需要执行非常复杂的任务。试图弄清楚如何完成这些任务可能非常具有挑战性。在这种情况下,我们经常使用自上而下的方法解决问题。也就是说,我们不是解决单个复杂的任务,而是将该任务分解为多个子任务,每个子任务都更容易单独解决。如果这些子任务仍然太难解决,则可以进一步分解。通过不断将复杂的任务拆分为更简单的任务,您最终可以达到每个单独任务都可以管理的地步,如果不是微不足道的话。
创建任务层次结构的另一种方法是自下而上地执行此操作。在此方法中,我们将从简单任务列表开始,并通过对它们进行分组来构建层次结构。
事实证明,这些任务层次结构在编程中非常有用,因为一旦您有了任务层次结构,您就基本上定义了整个程序的结构。顶级任务(在本例中为“打扫房子”或“上班”)变为main()(因为它是您尝试解决的主要问题)。子项成为程序中的函数。
5.找出事件顺序
现在您的程序有了结构,是时候确定如何将所有任务链接在一起了。第一步是确定将要执行的事件顺序。
实施步骤
1.
int main()
{
// doBedroomThings();
// doBathroomThings();
// doBreakfastThings();
// doTransportationThings();
return 0;
}
2.实现每个函数
3.最终测试
程序“完成”后,最后一步是测试整个程序并确保其按预期工作。如果它不起作用,请修复它。
编写程序时的建议:
保持程序易于启动。 通常,新程序员对他们希望程序做的所有事情都有一个宏伟的愿景。 “我想写一个角色扮演游戏,有图形和声音,还有随机的怪物和地牢,有一个城镇,你可以去卖你在地牢里找到的物品”。如果你试图写一些太复杂而无法开始的东西,你会因为你缺乏进步而变得不知所措和气馁。相反,让你的第一个目标尽可能简单,这绝对是你力所能及的。例如,“我希望能够在屏幕上显示二维字段”。
随着时间的推移添加功能。一旦您的简单程序运行良好,您就可以向其添加功能。例如,一旦您可以显示您的字段,请添加一个可以四处走动的角色。一旦你可以四处走动,添加可能阻碍你进步的墙壁。一旦你有了城墙,就用它们建造一个简单的城镇。一旦你有一个城镇,添加商人。通过逐步添加每个功能,您的程序将变得越来越复杂,而不会在此过程中让您不知所措。
一次专注于一个领域。不要试图一次编写所有代码,也不要将注意力分散在多个任务上。一次专注于一项任务。有一个工作任务和五个尚未开始的任务比六个部分工作的任务要好得多。如果你分散注意力,你更有可能犯错误并忘记重要的细节。
随时测试每段代码。新程序员通常会一次性编写整个程序。然后,当他们第一次编译它时,编译器会报告数百个错误。这不仅令人生畏,如果您的代码不起作用,可能很难找出原因。相反,编写一段代码,然后立即编译和测试它。如果它不起作用,您将确切地知道问题所在,并且很容易解决。确定代码有效后,转到下一段并重复。完成代码编写可能需要更长的时间,但是当你完成时,整个事情应该可以工作,你不必花费两倍的时间试图弄清楚为什么它没有。
不要投资完善早期代码。能(或程序)的初稿很少是好的。此外,程序往往会随着时间的推移而发展,因为您添加功能并找到更好的方法来构建事物。如果您过早地投资于完善代码(添加大量文档、完全符合最佳实践、进行优化),那么当需要更改代码时,您可能会失去所有这些投资。相反,让您的功能最低限度地工作,然后继续前进。当您对解决方案充满信心时,请连续涂抹抛光层。不要以完美为目标——非平凡的程序从来都不是完美的,总有更多的事情可以改进它们。达到“足够好”并继续前进。
针对可维护性而不是性能进行优化。有一句名言(唐纳德·高德纳)说:“过早的优化是万恶之源”。新程序员经常花太多时间思考如何对他们的代码进行微观优化(例如,试图找出 2 个语句中哪一个更快)。这很少重要。大多数性能优势来自良好的程序结构、使用正确的工具和功能来解决手头的问题,并遵循最佳实践。应使用额外的时间来提高代码的可维护性。找到冗余并将其删除。将长函数拆分为较短的函数。用更好的代码替换笨拙或难以使用的代码。最终结果将是以后更容易改进和优化的代码(在您确定实际需要优化的位置之后)和更少的错误。