learncpp-2 函数和文件
2 函数和文件
2.1 函数简介
- 函数是一个可重复使用的语句序列,旨在完成特定的工作。
- 函数定义由
函数头和函数体
组成。 - 函数定义里不能嵌套函数定义。
2.2 函数返回值
- 当程序执行时,
操作系统调用main函数
,最后main函数返回一个整数值,然后程序终止。 - main函数返回0说明程序执行正常结束
- 如果main函数中没有显式的return语句,则会隐式地返回0
2.3 void函数
- 在一个void函数中return一个值会产生编译错误;但是可以使用
return;
来提前终止函数
2.4 函数的形参和实参
函数形参
是在函数头中使用的变量,函数形参和函数体中定义的变量几乎相同:除了函数形参是用函数调用者提供的值来进行初始化的函数实参
是函数调用者传递给函数的值- 当一个函数被调用时,每次实参的值都会通过
拷贝初始化
复制到对应的形参中,这个过程叫做值传递
- 函数定义中的
形参可以省略名字
- 未使用到的函数形参可以用于区分
++
/--
运算符的重载是针对前缀形式还是后缀形式
2.5 局部作用域
- 函数体内部定义的变量称为
局部变量
- 函数参数通常也被认为是局部变量
- 变量的创建和销毁发生在程序运行时,而不是编译时。因此,
生存期是一个运行时属性
- 使用一个被销毁的对象会导致未定义的行为
如果是一个class类型对象,则在对象被销毁之前会调用
析构函数
作用域是一个编译时属性
,如果想要在作用域外使用变量会导致编译错误- 局部变量的作用域从变量定义的地方开始,到它所在的大括号的末尾结束(对于函数参数,则是到函数的末尾)。
- 不是所有类型的变量在超出作用域时都会被销毁,例如静态局部变量
- 临时对象(有时也称为匿名对象)是由编译器创建的用于临时存储值的未命名对象。(临时对象根本没有作用域,因为作用域是标识符的属性,而临时对象没有标识符)
2.6 有效使用函数
- 在程序中多次出现的语句组通常应组成一个函数。
- 具有定义明确的输入输出集的代码应该组成一个函数。
- 一个函数应该功能单一(执行一个任务)。
- 当一个函数变得太长、太复杂时应该进行
重构
。
2.7 前向声明和定义
- 编译器
按顺序
编译源码 - 当解决程序中的编译警告或者报错时,先解决提示信息中的第一个问题
前向声明
可以在实际定义一个标识符之前,告诉编译器这个标识符的存在函数声明
也叫函数原型
,包含函数的返回类型、函数名称、参数类型(参数名称可选),并以分号结尾- 如果一个函数存在前向声明并且被调用,但是没有真正的定义,则编译不会报错,但是链接会报错
声明
:告诉编译器某个标识符的存在以及它的类型信息
int add(int x,int y); // 告诉编译器有一个名为add的函数,它接收2个int参数,返回一个int
int x; // 告诉编译器有一个名为x的变量,它的类型是int
定义
:标识符的具体实现(对于函数和类)
或者标识符的实例化(对于变量)
// add函数的具体实现就是add函数的定义
int add(int x, int y)
{
int z{ x + y }; // 实例化变量z就是z的定义
return z;
}
int x; // 实例化变量x
- c++中,
所有的定义都是声明
,因此int x;
既是声明又是定义 并非所有的声明都是定义
,那些不是定义的声明叫做纯声明
。纯声明的类型包括函数、变量和类型的前向声明- 大部分时候,
声明
足以让编译器确保标识符被正确使用,例如只要有add(int,int)
函数的声明,那么编译器在碰到add函数的调用时就不会报错 - 在少数情况下,编译器必须知道知道完整的
定义
才能使用标识符(例如模板定义和类型定义)
术语 | 含义 | 示例 |
---|---|---|
声明 | 告诉编译器有关标识符及其关联的类型信息 | void foo(); // 函数前向声明,无函数体 void goo(){}; // 函数定义,有函数体 int x; // 变量定义 |
定义 | 实现一个函数或者实例化一个变量 定义都是声明 |
void goo(){}; // 函数定义,有函数体 int x; // 变量定义 |
纯声明 | 不属于定义的声明 | void foo(); // 函数前向声明,无函数体 |
初始化 | 为定义的对象提供初始值 | int x{2}; // x被初始化为2 |
- 单一定义规则(one definition rule, ODR)
- 在一个文件中,每个函数、变量、类型或模板在
给定的作用域中
只能有一个定义(在不同作用域中出现的定义不违反此规则,例如不同函数中定义的同名局部变量、不同命名空间中定义的同名函数
) - 在一个程序中,每个函数、变量在
给定的作用域中
只能有一个定义(链接器中不可见的函数和变量不包括在这个规则内) - 类型、模板、内联函数和内联变量在
不同的文件中
允许有重复的定义,只要每个定义是相同的
违反第一条规则会导致
编译器
发出重复定义错误;违反第二条规则导致链接器
发出重复定义错误;违反第三条规则会导致未定义的行为 - 在一个文件中,每个函数、变量、类型或模板在
2.8 多文件项目
- 编译器会
单独编译
每个源码文件,并且它并不会记得其他文件的内容
这样做的好处是可以按照任意顺序编译项目中的所有文件,并且可以只编译更改了的文件
2.9 命名冲突和命名空间
- 两个(或多个)同名函数(或全局变量)被引入到属于同一程序的不同文件中,这将导致链接器错误。
- 两个(或多个)同名函数(或全局变量)被引入到同一个文件中。这将导致编译器错误。
- 不同的作用域(例如命名空间)中可以有相同的标识符
- 只有
声明和定义
可以出现在命名空间的作用域中,可执行代码不能出现在命名空间中(但是命名空间中可以包含函数的定义,而函数的定义中可以包含可执行的代码) - 全局命名空间
- 任何没有在
类、函数、命名空间
中定义的名称都是隐式定义的命名空间的一部分,这个隐式定义的命名空间称为全局命名空间/全局作用域 - 在全局作用域内声明的标识符从声明处到文件末尾都是有效的
- 尽量避免在全局作用域中定义变量
#include
语句引入的声明也在全局作用域中
- 任何没有在
- std命名空间
- C++将标准库中的所有功能都移到了一个名为std的命名空间中
::
称为范围解析运算符
,如果::
左边省略了命名空间的名称,则默认使用全局命名空间- 当标识符包含命名空间前缀时,该标识符称为
限定名
- 避免使用using指令(例如
using namespace std;
),这可能会导致我们自己定义的标识符和std命名空间里的标识符产生冲突(这就是为什么要将标准库中的所有标识符移到std命名空间中的原因!!!)
#include "iostream"
int cout = 3;
int main() {
cout << "aaa"; // 编译报错:Invalid operands to binary expression ('int' and 'const char[4]') 因为cout是int
std::cout << "aaa"; // 编译通过
return 0;
}
#include "iostream"
using namespace std;
int cout = 3;
int main() {
cout << "aaa"; // 编译报错:Reference to 'cout' is ambiguous:candidate found by name lookup is 'cout';candidate found by name lookup is 'std::cout'
// 因为编译器不知道这里的cout是全局命名空间里的cout(int)还是std命名空间里的cout(ostream)
return 0;
}
2.10 预处理器
- 在编译之前,所有源码文件都会经过
预处理阶段
- 当预处理器完成对源码文件的处理后,得到的结果叫做
翻译单元
,这个翻译单元就是随后由编译器编译的内容
预处理、编译、链接的整个过程叫做
翻译
预处理器指令
:以#
开头的指令(结尾没有分号)- 当
#include
一个文件时,预处理器会将该文件的内容替换掉#include
指令 #define
指令用来创建宏
。在c++中,宏就是定义如何将输入文本替换为输出文本的规则
函数式宏
和函数的作用类似,但是它们的使用通常被认为是不安全的,它们做的任何事情都可以通过一个正常的函数来完成对象式宏
有两种定义方式
#define IDENTIFIER
#define IDENTIFIER substitution_text
第一种定义没有替换文本,第二种定义有替换文本
条件编译预处理器指令可以指定在某些条件下编译或不编译某些内容
#ifdef
预处理器指令会让预处理器检查一个标识符是否已经被#define
过。如果被#define
过,则#ifdef
和#endif
之间的代码将会被编译,否则不会被编译
#include <iostream>
#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
的作用相反#if 0
可以让一个代码块不被编译- 宏只会将
非预处理命令中的文本
进行替换 - 预处理器处理后的结果中不会包含任何预处理命令
- 一个文件中的所有已定义的标识符都会在预处理结束后被丢弃,因此一个文件中定义的指令不会对其他文件有任何影响(除非这些指令被include到其他文件中)
2.11 头文件
- 头文件可以允许我们将声明放在一个位置,然后在需要的地方导入它们。这样避免在多个文件中输入相同的声明
- 头文件由两部分组成:
头文件保护符
和前向声明
- 头文件和对应的代码文件应该同名,例如
add.cpp
和add.h
不要在头文件中定义函数或变量
,否则可能会违反单一定义原则
(在头文件被引入多个源码文件的情况下)
内联函数、内联变量、类型和模板
的定义可以出现在头文件中
- 源码文件最好引入同名的头文件,这样有助于在编译期发现一些错误
- 不要include源码文件(这样可能会导致命名冲突或者违背单一定义原则)
- 使用
<>
是告诉预处理器去包含编译器自带头文件的目录里去搜索,不会在项目的源代码目录中搜索;使用""
则先在当前目录中搜索,如果没有再去包含编译器自带头文件的目录里搜索 - 如果要包含其他目录下的头文件,可以在
#include
命中使用相对路径或者在IDE中设置include path/search directory
- 头文件可以包含头文件
- 每个文件都应该显式地引入它编译所需的所有头文件,不要依赖被头文件包含而传递进来的头文件
#include
命令的顺序
- 源码文件对应的头文件
- 项目中的其他头文件
- 第三方库的头文件
- 标准库头文件
每个分组的多个头文件应该按字母进行排序(除非第三方库的文档明确要求不要这么做)
这种顺序的好处是:如果自定义的头文件忘记include第三方库或者标准库的头文件,那么在编译的时候会报错
- 头文件使用建议:
- 总是包含
头文件保护符
- 不要在头文件中定义变量和函数
- 头文件和对应的源码文件应该具有相同的名字
- 每个头文件应该有一个特定的任务,并且尽可能独立
- 显式引入所需的头文件,避免传递性地引入
- 不要引入源码文件
- 头文件中应该进行功能说明和使用说明,具体的功能实现应该放在源码文件中
- 总是包含
2.12 头文件保护符
- 重复定义变量或函数会导致编译错误
- 头文件保护符就是
条件编译指令
#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE
// your declarations (and certain types of definitions) here
#endif
- 头文件保护符只能避免在
同一个文件
中多次引入同一个头文件的定义(编译错误)(虽然不建议在头文件中定义函数或变量),但是无法避免不同文件
都引入同一个头文件的定义(链接错误)
解法办法就是将定义放在源码文件中,而头文件只包含前向声明
#pragma once
是另一种头文件保护符的写法,具体实现由编译器决定
如果一个头文件在多个地方有相同的副本,并且这些副本同时被引入,那么传统写法可以保证相同的内容不会被重复引入,但是
#pragma once
会失败,因为编译器不知道这些副本的内容是相同的
#pragma
指令的具体实现是由编译器决定的,因此可能有些编译器并不支持#pragma
指令
2.13 如何设计程序
- 定义目标
- 定义需求
- 定义工具、目标和备份计划
- 拆解复杂任务
- 确定执行顺序
- 概述主程序
- 实现每个函数
- 总体测试
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 25岁的心里话
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现