命名空间
为什么要使用命名空间
命名冲突指的是在同一个作用域内,存在两个或多个具有相同名称的实体。
在 C 语言中,避免此类冲突通常需要开发者遵循特定的命名约定。例如,通过在变量名前添加开发者的标识符,可以在一定程度上减少冲突的可能性:
int hw_cpp_tom_num = 100;
int xm_cpp_bob_num = 200;
C++ 提供了一种解决方案,即 命名空间。命名空间允许开发者定义自己的命名作用域,使得在不同作用域中可以存在同名的变量或函数,而不会相互干扰。这样,系统能够根据命名空间来区分这些同名实体,从而有效地解决了命名冲突的问题。
什么是命名空间
命名空间(Namespace)是一种编程概念,它允许程序员创建命名的内存区域,用于组织和隔离代码中的全局实体。通过将全局实体分配到不同的命名空间中,可以有效地避免命名冲突,因为每个命名空间都定义了自己的作用域,其中的实体只在该命名空间内可见。
在 C++ 中,命名空间的使用不仅有助于解决命名冲突,还能提高代码的模块化和可读性。以下是 C++ 中定义命名空间的基本格式:
namespace ccb {
int val1 = 0;
char val2;
} // namespace ccb
在声明一个命名空间时,大括号内可以包含多种类型的实体,包括:
- 变量:存储数据的变量。
- 常量:存储固定值的常量。
- 函数:执行特定操作的代码块。
- 结构体:用于数据组织的结构。
- 引用:指向其他变量或实体的引用。
- 类:定义对象的数据结构和行为的模板。
- 对象:类的实例。
- 模板:用于创建泛型函数和类的代码。
- 命名空间:可以嵌套定义,进一步组织代码。
命名空间的使用方式
命名空间一共有三种使用方式,分别是 using 编译指令、作用域限定符、using 声明机制。
在 C++中,使用 作用域限定符 ::
来访问命名空间中的实体是一种非常直接和明确的方法。这种方式确保了对命名空间内实体的精确访问,避免了命名冲突,并且使得代码的意图更加清晰。
作用域限定符
在代码中,通过在实体名前加上命名空间名和作用域限定符 ::
,可以直接访问该命名空间内的任何实体。这种方法虽然在某些情况下显得繁琐,但它提供了最高的精确度和控制力。
namespace ccb {
int number = 10;
void display() {
std::cout << "ccb::display()" << std::endl;
}
} // namespace ccb
void test0() {
std::cout << "ccb::number = " << ccb::number << std::endl;
ccb::display();
}
#include <iostream>
int main(int argc, char *argv[]) {
std::cout << "hello, world" << std::endl;
return 0;
}
优点:
- 精确性:通过明确指定命名空间,可以确保访问正确的实体,避免因命名冲突导致的错误。
- 清晰性:代码的读者可以立即识别出实体所属的命名空间,增加了代码的可读性。
缺点:
- 繁琐性:每次访问命名空间内的实体都需要使用作用域限定符,这在实体频繁使用时会增加代码的冗余。
- 可维护性:如果命名空间的名称更改,需要在整个代码库中进行相应的修改,这可能会影响代码的维护性。
using 编译指令
在 C++编程中,std
命名空间是标准库中所有实体的集合,包括常用的 cout
和 endl
。为了简化代码,可以使用 using namespace std;
指令,这样可以直接访问这些实体而无需每次使用前缀 std::
。
#include <iostream>
using namespace std; // 引入标准命名空间
int main(int argc, char *argv[]) {
cout << "hello, world" << endl;
return 0;
}
尽管 using namespace
可以简化代码,但过度使用可能导致命名冲突,尤其是在大型项目中。因此,建议仅在局部作用域内使用此指令。
例如,当两个命名空间中存在同名实体时,直接使用 using namespace
可能会导致编译器无法确定使用哪个实体,从而引发冲突:
namespace cpp {
int number = 100;
} // namespace cpp
namespace ccb {
int number = 10;
void display() {
cout << "ccb::display()" << endl;
}
} // namespace ccb
using namespace ccb;
void test0() {
cout << number << endl; // 正确,因为ccb命名空间已被引入
}
using namespace cpp;
void test1() {
cout << number << endl; // 错误,因为存在命名冲突
}
为了避免这种冲突,建议将 using namespace
指令放在局部作用域内,并在必要时使用命名空间限定符来明确指定实体来源。
namespace cpp {
int number = 100;
} // namespace cpp
namespace ccb {
int number = 10;
} // namespace ccb
void test0() {
using namespace ccb;
using namespace cpp;
cout << number << endl; // 错误,仍然存在命名冲突
}
此外,当全局命名空间中也存在同名实体时,使用 using namespace
也可能导致冲突:
int number = 100;
namespace ccb {
int number = 10;
void display() {
cout << "ccb::display()" << endl;
}
} // namespace ccb
void test0() {
using namespace ccb;
cout << number << endl; // 错误,存在命名冲突
}
建议: 在不确定命名空间中实体的情况下,谨慎使用 using namespace
,以避免不必要的命名冲突。对于初学者,建议先熟悉命名空间中的实体,再考虑是否使用 using namespace
指令。
使用 using namespace
的好处是代码更简洁,但坏处是可能引发命名空间污染和访问冲突。
using 声明机制
在 C++中,using
声明提供了一种更精确的方式来引入命名空间中的特定实体,而不是将整个命名空间的内容全部引入。这种方式有助于减少命名冲突,并提高代码的清晰度。
使用 using
声明的好处:
- 精确控制: 只引入所需的实体,避免不必要的命名空间污染。
- 减少冲突: 通过局部引入,减少命名冲突的可能性。
- 提高可读性: 明确指出哪些实体被使用,使代码更易理解。
示例:
#include <iostream>
using std::cout;
using std::endl;
int number = 100;
namespace ccb {
int number = 10;
void display() {
cout << "ccb::display()" << endl;
}
} // namespace ccb
int main() {
using ccb::number;
using ccb::display; // 只引入函数名
cout << "ccb::number = " << number << endl; // 正确,访问到 ccb::number
display();
return 0;
}
在这个例子中,我们通过 using
声明引入了 ccb
命名空间中的 number
和 display
实体。这样,即使全局命名空间中也存在名为 number
的变量,局部作用域中仍然可以明确访问 ccb
命名空间中的 number
。
冲突处理: 如果在同一作用域内使用 using
声明引入了不同命名空间中的同名实体,仍然会发生冲突。例如:
namespace ccb {
int number = 10;
void display() {
cout << "ccb::display()" << endl;
}
} // namespace ccb
namespace ccb2 {
void display() {
cout << "ccb2::display()" << endl;
}
} // namespace ccb2
void test0() {
using ccb::display;
using ccb2::display; // 冲突
display(); // 编译错误
ccb::display();
ccb2::display();
}
在这个例子中,尝试在同一个作用域内引入两个不同命名空间中的 display
函数,导致了冲突。正确的做法是避免在同一作用域内引入同名实体,或者使用命名空间限定符来明确指定要使用的实体。
using
声明机制是一种有效的方法,用于精确控制命名空间中实体的引入,减少命名冲突,并提高代码的可读性和可维护性。在编写 C++代码时,推荐使用这种方式来引入所需的命名空间实体。
命名空间的嵌套使用
namespace ccb {
int num = 100;
void func() {
cout << "func" << endl;
}
namespace cpp {
int num = 200;
void func() {
cout << "cpp::func" << endl;
}
} // namespace cpp
} // namespace ccb
// 方式一,使用作用域限定精确访问实体
void test0() {
cout << ccb::cpp::num << endl;
ccb::cpp::func();
}
// 方式二,using 编译指令一次性引入 cpp 的实体
void test1() {
using namespace ccb::cpp;
cout << num << endl;
func();
}
// 方式三,using 声明语句
void test2() {
using ccb::cpp::num;
using ccb::cpp::func;
cout << num << endl;
func();
}
匿名命名空间
在 C++中,匿名命名空间(也称为匿名空间)是一种特殊的命名空间,它没有名字,其作用域限定在定义它的文件内。这意味着在匿名命名空间中定义的实体(如变量、函数等)只能在该文件内部访问,无法在其他文件中直接访问,从而实现了良好的封装和避免命名冲突。
定义匿名命名空间:
namespace {
int val = 10;
void func() {
cout << "func()" << endl;
}
} // namespace
使用匿名命名空间中的实体:
void test0() {
cout << val << endl; // 直接访问
cout << ::val << endl; // 使用全局作用域限定符访问
func(); // 直接访问
::func(); // 使用全局作用域限定符访问
}
注意事项:
- 避免命名冲突: 如果在匿名命名空间中定义了与全局命名空间中同名的实体,直接访问时会发生冲突。使用全局作用域限定符
::
也无法访问到匿名命名空间中的实体,只能访问全局实体。int val = 100; void func() { cout << "g_func()" << endl; } namespace { int val = 10; void func() { cout << "func()" << endl; } } // namespace void test0() { cout << val << endl; // 冲突,访问全局的 val cout << ::val << endl; // 全局的 val func(); // 冲突,访问全局的 func ::func(); // 全局的 func }
- 匿名空间的实体不能跨模块调用: 匿名命名空间中的实体仅在定义它们的文件内可见,无法在其他文件中访问。
概念澄清:
- 匿名命名空间:没有名字的命名空间,其作用域限定在定义它的文件内。
- 具名命名空间:有名字的命名空间,可以在多个文件中定义和使用。
- 命名空间:包括匿名命名空间和具名命名空间,用于封装代码,避免命名冲突。
int num = 100;
void func() {
cout << "func1()" << endl;
}
//如果希望将一些内容限定在本文件中使用
//就可以将它们定义在匿名空间中
//同时,要注意不要在全局位置定义同名的实体
//否则在本文件中只能访问到全局的内容
//无法访问匿名空间中重名的实体
namespace {
int num = 10;
void func() {
cout << "func2()" << endl;
}
} // namespace
//通常使用匿名空间中实体时直接写实体的名字
void test0() {
cout << ::num << endl;
::func;
}
跨模块调用问题
在 C++中,全局变量和函数可以跨模块调用,这是通过在其他模块中使用 extern
关键字声明这些全局变量和函数来实现的。
extern
声明告诉编译器这些变量和函数的定义在其他地方存在,因此编译器在链接时会将这些声明与它们的定义关联起来。
全局变量和函数是可以跨模块调用的
// externA.cc
int num = 100;
void print() {
cout << "print()" << endl;
}
//////////////////////////////
// externB.cc
extern int num; // 外部引入声明
extern void print();
void test0() {
cout << num << endl;
print();
}
在上述示例中,externA.cc
定义了全局变量 num
和函数 print
。externB.cc
通过 extern
声明引入了这些全局变量和函数,使得它们可以在 externB.cc
中被调用。
有名命名空间中的实体可以跨模块调用
对于命名空间中的实体,也可以实现跨模块调用,但需要在每个模块中声明同名的命名空间,并使用 extern
关键字引入实体。
// externA.cc
namespace ccb {
int val = 300;
void display() {
cout << "ccb::display()" << endl;
}
} // namespace ccb
//////////////////////////////
// externB.cc
namespace ccb {
extern int val; // 在 ccb 命名空间中引入 val
extern void display(); // 在 ccb 命名空间中引入 display
} // namespace ccb
void test0() {
cout << ccb::val << endl;
ccb::display();
}
在上述示例中,externA.cc
定义了命名空间 ccb
及其中的变量 val
和函数 display
。externB.cc
同样声明了命名空间 ccb
,并在其中使用 extern
关键字引入了 val
和 display
。这样,externB.cc
就可以访问这些实体。
命名空间中的实体跨模块调用时,要在新的源文件中再次定义同名的命名空间,在其中通过 extern 引入实体。
#include <iostream>
// #include "externA.cc"
using std::cout;
using std::endl;
extern int num;
extern void print();
void test0() {
cout << num << endl;
print();
}
namespace ccb {
extern int val;
extern void display();
// void display();
} // namespace ccb
void test0() {
cout << ccb::val << endl;
ccb::display();
}
// externA.cc
int val = 100; // 全局位置定义的变量、函数与命名空间中的实体同名
void display() {
cout << "display()" << endl;
}
namespace ccb {
int val = 300;
void display() {
cout << "ccb::display()" << endl;
}
} // namespace ccb
//////////////////////////////
// externB.cc
extern int val;
extern void display();
namespace ccb {
extern int val;
extern void display();
}
// 访问到全局的实体
void test0() {
cout << val << endl;
display();
}
// 访问到命名空间中的实体
void test1() {
cout << ccb::val << endl;
ccb::display();
}
void test2() {
using namespace ccb;
cout << val << endl; // 冲突
display(); // 冲突
}
using ccb::val;// 声明冲突
using ccb::display;// 声明冲突
void test3() {
// ...
}
void test4() {
using ccb::val;
using ccb::display;
cout << val << endl; // ok,在本作用域中对全局的实体起到了屏蔽的效果
display(); // ok
}
如果需要跨模块调用命名空间中的实体,要尽量避免它们与全局位置的实体重名,在使用时尽量采取作用域限定的方式。
静态变量和函数只能在本模块内部使用
匿名空间的实体只能在本模块内部使用
匿名空间中的实体只能在本文件的作用域内有效,它的作用域是从匿名命名空间声明开始到本文件结束。
extern 和 include
extern 的使用
extern
关键字用于声明全局变量或函数,告诉编译器这些变量或函数的定义在其他文件中。这种方式非常适合于管理较小的代码组织,因为它允许程序员明确指定需要引入哪些外部定义。
优点:
- 精细控制: 可以精确地控制哪些外部变量或函数被引入,避免不必要的代码引入。
- 减少依赖: 减少对其他模块的依赖,因为只有需要的变量或函数才会被声明。
缺点:
- 易出错: 如果跨模块的调用关系不清晰,或者忘记声明某个外部变量或函数,很容易导致编译错误或运行时错误。
- 维护困难: 当项目规模增大时,跟踪和管理所有的
extern
声明可能会变得复杂。
include 的使用
include
指令用于包含头文件,这些头文件中通常包含了函数声明、模板定义、宏定义等。使用 include
可以确保所有相关的声明都在编译时可用。
优点:
- 代码清晰: 通过包含头文件,可以确保所有相关的声明都在一个地方管理,使得代码结构更清晰。
- 自动更新: 当头文件更新时,所有包含该头文件的源文件都会自动更新,保持一致性。
缺点:
- 效率较低: 因为
include
会一次性引入头文件中的所有内容,即使有些内容在当前文件中并未使用,这可能导致编译效率降低。 - 循环依赖: 过度使用
include
可能导致头文件之间的循环依赖,使得项目结构变得复杂。
对比和选择
- 小型项目或简单模块: 如果项目较小或者模块之间的依赖关系简单,使用
extern
可能更合适,因为它提供了更精细的控制。 - 大型项目或复杂模块: 对于大型项目或模块化程度较高的项目,使用
include
可能更合适,因为它有助于保持代码的清晰和一致性。
使用命名空间的规则
多次定义
命名空间可以在多个地方定义,但所有定义都被视为同一个命名空间。这意味着在同一个命名空间中定义的实体在整个程序中是唯一的。
namespace ccb {
int num = 100;
void print() {
cout << "print()" << endl;
}
} // namespace ccb
namespace ccb {
int num2 = 300;
} // namespace ccb
在这个例子中,ccb
命名空间被定义了两次,但它们被视为同一个命名空间。
重复定义
不能在同一个命名空间中定义同名的实体。
namespace ccb {
int num = 100;
void print() {
cout << "print()" << endl;
}
} // namespace ccb
namespace ccb {
int num2 = 300;
int num = 100; // 错误:不能在同一个命名空间中重新定义 num
} // namespace ccb
实体使用
命名空间中的实体(如变量和函数)不能在命名空间内部使用,它们必须在命名空间外部被访问和使用。
namespace ccb {
void func();
void func2() {
cout << "func2()" << endl;
}
func2(); // 错误:不能在命名空间内部调用函数
int num = 10;
num = 100; // 错误:不能在命名空间内部访问变量
}
void test1() {
ccb::func2(); // 正确:在命名空间外部调用函数
ccb::num = 100; // 正确:在命名空间外部访问变量
cout << ccb::num << endl;
}
总结
命名空间的作用
- 避免命名冲突: 通过将相关的实体封装在命名空间中,可以避免不同代码库或模块间的命名冲突。
- 组织代码: 将逻辑上相关的实体(如类、函数、变量等)组织在一起,使代码更加模块化和易于管理。
- 版本控制: 通过在不同的命名空间中放置不同版本的实体,可以方便地进行版本控制和迁移。
- 声明主权: 在一定程度上,命名空间的使用可以表明代码的所有权或来源,有助于维护和责任分配。
命名空间使用建议
- 定义变量: 推荐在命名空间中定义变量,而不是直接使用全局变量或静态全局变量。这有助于限制变量的作用域,减少意外的全局影响。
- 局部 using 声明: 建议将
using
声明语句的作用域限制在局部,而不是全局。这样可以减少命名冲突的风险,并提高代码的清晰度。 - 避免在头文件中使用 using 指令: 在头文件中使用
using
指令可能会导致命名空间污染,因为包含该头文件的源文件可能会无意中引入了不需要的命名空间实体,造成二义性问题。 - 头文件包含顺序: 推荐首先包含自定义头文件,然后是 C 标准库头文件,接着是 C++ 标准库头文件,最后是第三方库头文件。这种顺序有助于减少编译依赖和提高编译效率。
包含头文件的顺序
- 自定义头文件: 首先包含,因为它们可能依赖于项目特定的配置或定义。
- C 标准库头文件: 其次包含,因为它们提供了基本的 C 语言功能。
- C++标准库头文件: 接着包含,因为它们提供了 C++ 语言的核心功能。
- 第三方库头文件: 最后包含,因为它们可能依赖于前面的标准库或自定义头文件。
const 关键字
修饰内置类型
const
修饰的变量在 C++ 中被称为常量,它们一旦被初始化后就不能被修改。这些常量可以是整型、浮点型或其他任何基本数据类型。const
常量在定义时必须立即初始化,因为它们在程序运行时存储在内存中,并且编译器需要知道它们的确切值。
const
常量的特点:
- 类型安全:
const
常量具有明确的类型,编译器会进行类型检查。 - 编译时处理:
const
常量在编译时处理,它们在内存中占有空间,并且可以参与编译时的优化。 - 初始化要求:必须在定义时初始化,因为它们在程序运行时存储在内存中。
- 作用域限制:
const
常量具有作用域限制,它们的作用域由定义它们的位置决定。
const int number1 = 10; // 正确的初始化
int const number2 = 20; // 等价于上面的声明
const int val; // 错误:常量必须要进行初始化
宏定义常量是通过预处理器指令 #define
创建的,它们在预处理阶段进行文本替换,没有类型,也不进行类型检查。
#define NUMBER 1024
const 常量和宏定义常量的区别
- 发生的时机不同:
- 宏定义在预处理时发生,是简单的文本替换。
const
常量在编译时处理,具有类型和作用域。
- 类型和安全检查不同:
- 宏定义没有类型,不进行类型检查。
const
常量有类型,编译器会进行类型检查。
- 内存占用:
const
常量在内存中占有空间,可以有构造和析构。- 宏定义不占用内存,它们只是文本替换。
- 调试和维护:
const
常量更容易调试,因为它们有类型和作用域。- 宏定义可能更难调试,因为它们在预处理阶段就被替换了。
修饰指针类型
在 C++ 中,使用 const
关键字修饰指针可以有多种含义,取决于 const
的位置。以下是三种主要的修饰方式:
指向常量的指针(Pointer to const)
当 const
位于星号(*)的左侧时,表示指针指向的值是常量,不能通过这个指针来修改它指向的值。
int number1 = 10;
int number2 = 20;
const int *p1 = &number1; // 指向常量的指针
*p1 = 100; // 错误:不能通过 p1 修改它指向的值
p1 = &number2; // 可以改变 p1 指针的指向
如果有一个 const
修饰的变量,那么只有 const
修饰的指针才能指向它,因为 const
修饰的变量的值不能被修改。
const int x = 20;
int * p = &x; // 错误
const int * cp = &x; // 正确
// 还有一个意义
// 只有指向常量的指针才能去指向 const 常量
const int num3 = 1;
int *p2 = &num3;
const int *p2 = &num3;
// 指向常量的指针不仅能够指向 const 常量
// 还可以指向普通的变量
p2 = &num2;
const int * p1
和 int const * p1
是等价的,都表示指向常量的指针。
int const * p2 = &number1; // 指向常量的指针的第二种写法
常量指针(Const pointer)
当 const
位于星号(*)的右侧时,表示指针本身是常量,一旦指针被初始化后,就不能改变它的指向。
int * const p2 = &number1; // 常量指针
*p2 = 100; // 可以修改它指向的值
p2 = &number2; // 错误:不能改变p2指针的指向
指向常量的常量指针(Const pointer to const)
当指针和它指向的值都是 const
时,这种指针既不能改变指向,也不能通过这个指针修改它指向的值。
const int * const p3 = &number1; // 指向和指向的值皆不能进行修改
*p3 = 100; // 错误:不能通过 p3 修改它指向的值
p3 = &number2; // 错误:不能改变 p3 指针的指向
数组指针(Pointer to array)
// 数组指针, 指向数组的指针
void test0() {
int arr[5] = {1, 2, 3, 4, 5};
// 数组中首个元素的首地址
int *p = arr;
cout << arr << endl;
cout << arr + 1 << endl;
cout << endl;
// 数组指针
// 整个数组的首地址
int (*p2)[5] = &arr;
cout << &arr << endl;
cout << &arr + 1 << endl;
for (int idx = 0; idx < 5; ++idx) {
cout << (*p2)[idx] << " ";
}
cout << endl;
}
// 0x7ffffcbe0
// 0x7ffffcbe4
//
// 0x7ffffcbe0
// 0x7ffffcbf4
// 1 2 3 4 5
指针数组(Array of pointers)
// 指针数组
// 元素类型为指针的数组
void test1() {
int num = 1, num2 = 2, num3 = 3;
int *p1 = #
int *p2 = &num2;
int *p3 = &num3;
int *arr[3] = {p1, p2, p3};
for (int idx = 0; idx < 3; ++idx) {
cout << *arr[idx] << " ";
}
cout << endl;
}
// 1 2 3
函数指针(Pointer to function)
// 函数指针
// 指向函数的指针
void func(int x) {
cout << "func() " << x << endl;
}
void test2() {
// 定义函数指针要确定其指向的函数的
// 返回类型和参数信息
//
// 简略写法
void (*p)(int) = func;
p(4);
// 完整写法
void (*p2)(int) = &func;
(*p2)(7);
}
// func() 4
// 7func() 7
指针函数(Function that returns a pointer)
// 指针函数
// 返回值为指针的函数
//
// 返回的指针指向的内容生命周期应该比函数更长
int num = 100;
int *func2() {
return #
}
void test3() {
cout << *func2() << endl;
}
// 100
总结
- 指向常量的指针(Pointer to const)
const int* ptr;
- 描述:指针指向一个常量值,不能通过这个指针修改它指向的值。
- 用途:用于只读数据。
- 常量指针(Const pointer)
int* const ptr;
- 描述:指针本身的值是常量,一旦指针被初始化后,就不能改变它的指向。
- 用途:用于确保指针的指向不变。
- 指向常量的常量指针(Const pointer to const)
const int* const ptr;
- 描述:既不能改变指针指向的值,也不能改变指针的指向。
- 用途:用于确保指针和数据的不可变性。
- 数组指针(Pointer to array)
int (*ptr)[10];
- 描述:指针指向一个数组。
- 用途:用于处理数组,特别是多维数组或数组的数组。
- 指针数组(Array of pointers)
int* ptrs[10];
- 描述:一个数组,其元素是指针。
- 用途:用于管理一组指针,例如管理一组对象或资源。
- 函数指针(Pointer to function)
void (*ptr)(int);
- 描述:指针指向一个函数。
- 用途:用于回调函数、函数指针数组等。
- 指针函数(Function that returns a pointer)
int* func();
- 描述:一个函数,它返回一个指针。
- 用途:用于动态内存分配、返回对象的指针等。
比较和用途
- 指向常量的指针 和 常量指针 的主要区别在于限制的方向:前者限制了通过指针修改数据的能力,后者限制了改变指针指向的能力。
- 指向常量的常量指针 结合了前两者的限制,提供了最严格的限制。
- 数组指针 通常用于处理更复杂的数据结构,如多维数组。
- 指针数组 则用于管理多个指针,常用于动态数据结构。
- 函数指针 在事件驱动和回调机制中非常有用,也常用于 API 设计。
- 指针函数 则提供了一种返回复杂数据结构或动态分配内存的方式。
new/delete 表达式
C/C++申请、释放堆空间的方式对比
C 语言中的内存管理
在 C 语言中,使用 malloc
和 free
函数来管理动态内存。这两个函数分别用于分配和释放内存。
malloc
函数:它根据指定的大小(以字节为单位)分配一块内存,并返回一个指向这块内存的指针。如果分配失败,返回NULL
。free
函数:用于释放先前使用malloc
分配的内存。
#include <stdlib.h>
int main() {
int *p = (int*)malloc(sizeof(int)); // 分配内存
if (p != NULL) {
*p = 10; // 初始化
// 使用内存
free(p); // 释放内存
}
return 0;
}
C++中的内存管理
C++ 提供了 new
和 delete
运算符,这些运算符不仅分配内存,还调用对象的构造函数和析构函数,使得内存管理更加安全和方便。
new
运算符:分配内存,并调用对象的构造函数来初始化对象。如果分配失败,抛出std::bad_alloc
异常。delete
运算符:释放内存,并调用对象的析构函数。
#include <iostream>
int main() {
int *p1 = new int(); // 初始化为默认值(0)
std::cout << *p1 << std::endl; // 0
int *p2 = new int(1); // 初始化为指定值(1)
std::cout << *p2 << std::endl; // 1
delete p1; // 释放内存
delete p2; // 释放内存
return 0;
}
malloc/free 和 new/delete 的区别
- 库函数 / 运算符:
malloc
和free
是 C 标准库提供的函数,分别用于分配和释放内存。它们定义在<stdlib.h>
(或在 C++ 中为<cstdlib>
)。new
和delete
是 C++ 的运算符,它们不仅分配内存,还调用构造函数和析构函数。它们是语言的一部分,不是库函数。
- 返回类型:
malloc
返回void*
类型的指针,这意味着返回的指针需要显式转换为需要的指针类型。new
表达式返回一个具体类型的指针,无需显式类型转换。
- 初始化:
malloc
分配的内存不会自动初始化,它可能包含任意的“脏数据”。new
表达式可以初始化对象。如果不提供初始化器,对象将被默认初始化(零初始化对于基本类型和类的对象)。
- 内存大小参数:
- 使用
malloc
时,需要手动计算并指定所需内存的字节数。 new
运算符会自动计算所需的内存大小(基于对象的类型和构造函数参数),使得代码更加简洁和安全。
- 使用
- 构造和析构:
malloc
不涉及任何构造函数的调用,它只是分配内存。new
运算符在分配内存后会调用对象的构造函数,delete
运算符在释放内存前会调用对象的析构函数。
- 异常处理:
malloc
在内存分配失败时返回NULL
,需要手动检查返回值。new
在内存分配失败时抛出std::bad_alloc
异常(除非使用了new (nothrow)
),这使得错误处理更加统一和方便。
- 类型安全:
malloc
由于返回void*
,不是类型安全的。new
和delete
是类型安全的,因为它们知道分配和释放的对象类型。
new 表达式申请数组空间
申请并默认初始化数组:
int *p = new int[10]();
for (int idx = 0; idx < 10; ++idx) {
p[idx] = idx;
}
for (int idx = 0; idx < 10; ++idx) {
cout << p[idx] << " ";
}
cout << endl;
delete[] p;
p = nullptr;
在这个例子中,new int[10]()
分配了一个包含 10 个整数的数组,并默认初始化所有元素为 0。然后通过循环为数组的每个元素赋值。最后,使用 delete[]
来释放数组占用的内存。
使用大括号初始化数组:
// 如果想要指定数组元素初始化的值,
// 不能用小括号
// 只能用大括号
int *p2 = new int[10]{1, 2, 3, 4, 5};
for (int idx = 0; idx < 10; ++idx) {
cout << p2[idx] << " ";
}
cout << endl;
// 直接输出指针变量名就是输出
// 指针变量所保存的地址值
cout << p2 << endl;
delete[] p2;
p2 = nullptr;
在这个例子中,new int[10]{1, 2, 3, 4, 5}
分配了一个包含 5 个整数的数组,并通过大括号直接初始化数组的元素。这种方式简洁明了,特别适合数组元素已知且数量不多的情况。
使用 new 表达式申请堆空间存放字符串:
/* char * pstr = new char[6]{"hello"}; */
char *pstr = new char[6]{'h', 'e', 'l', 'l', 'o'};
for (int idx = 0; idx < 6; ++idx) {
cout << pstr[idx] << " ";
}
cout << endl;
// 输出流运算符对 char* 有默认重载效果
// 会自动去访问该地址上的内容
cout << pstr << endl;
printf("%p\n", pstr);
delete[] pstr;
pstr = nullptr;
回收空间时的注意事项
申请空间和回收空间的匹配
行为 | 申请 | 回收 |
---|---|---|
C 语言 | malloc |
free |
C++ 非数组 | new |
delete |
C++ 数组 | new[] |
delete[] |
如果错误地匹配申请和回收操作,比如使用 free
来释放由 new
申请的内存,或者使用 delete
来释放由 malloc
申请的内存,可能会导致未定义的行为,包括内存泄漏、程序崩溃或其他难以预测的错误。
安全回收
在 C++ 中,delete
或 delete[]
操作后,指针本身并不自动变为无效。它仍然持有它被删除的内存的地址,这样的指针称为“野指针”(Dangling Pointer)。引用野指针可能导致未定义行为,因为它指向的内存已经不再有效。
为了避免野指针带来的风险,最佳实践是在释放内存后立即将指针设置为 nullptr
。在 C++11 及更高版本中,nullptr
是表示空指针的首选方式,它比老式的 NULL
更为明确和安全。
int *p1 = new int(); // 初始化为该类型的默认值
std::cout << *p1 << std::endl;
delete p1;
p1 = nullptr; // 安全回收,避免野指针
引用
引用的概念
在 C++中,引用(reference)是一种变量的别名,它为变量提供了另一个名字。引用在声明时必须初始化,并且一旦与某个变量绑定,就不能再与另一个变量绑定。
- 定义:
- 引用在声明时使用
类型& 引用名 = 变量名;
的形式。 - 引用的类型必须与其绑定的变量类型相同。
- 引用在声明时使用
- 初始化:
- 引用在声明时必须初始化,不能留空。
- 初始化后,引用就与被绑定的变量紧密关联,任何对引用的操作都等同于对原变量的操作。
- 不可更改绑定:
- 一旦引用被绑定到一个变量上,就不能重新绑定到另一个变量上。
- 地址:
- 引用和其绑定的变量共享相同的内存地址。
&ref
将得到引用所绑定变量的地址。
void test0() {
int num = 100;
int & ref = num; // 声明 ref 时进行了初始化(绑定)
int & ref2; // 错误,引用未初始化
cout << "num: " << num << endl;
cout << "ref: " << ref << endl;
cout << "Address of num: " << &num << endl;
cout << "Address of ref: " << &ref << endl;
}
// num: 100
// ref: 100
// Address of num: 0x7ffeedf677a8
// Address of ref: 0x7ffeedf677a8
引用的本质
在 C++中,引用是一种特殊的指针,它提供了一种间接访问变量的方式。
普遍的观点认为引用本身不占用内存空间,它仅仅是一个变量的另一个名字。但如果从原理去理解,就会发现引用实际上是通过指针来实现的,因此它确实会占用内存空间,其大小等同于一个指针的大小。
引用变量占用内存,存储的是一个地址值。编译器限制了对这个地址的直接访问,确保引用始终指向它最初被绑定的那个变量。在汇编语言层面,引用的操作本质上是基于“间接寻址”的概念。
通过尝试对引用进行取址操作可以发现得到的地址正是引用所绑定的变量的地址。
int num = 10;
/* int & ref; // 错误操作 */
int &ref = num;
int num2 = 100;
ref = num2; // 赋值操作
cout << "num:" << num << endl; // num:100
cout << endl;
cout << &num << endl; // 0x7ffd55322d20
cout << &ref << endl; // 0x7ffd55322d20
cout << &num2 << endl; // 0x7ffd55322d24
cout << endl;
int *p = #
cout << &p << endl; // 0x7ffd55322d28
cout << p << endl; // 0x7ffd55322d20
return 0;
引用与指针的联系与区别
联系:
- 间接访问:引用和指针都允许我们通过一个间接的方式访问变量,这意味着它们不直接存储数据,而是存储数据的地址或指向数据的指针。
-
底层实现:引用在底层实际上是通过指针来实现的。这意味着引用的内部机制与指针类似,都是通过地址来访问数据。可以将引用视为一种特殊的指针,通常是一个常量指针(const pointer),意味着一旦引用被初始化指向一个变量,它就不能被重新指向另一个变量。
区别:
- 初始化要求:
- 引用:必须在声明时立即初始化,并且一旦初始化后,就不能改变其指向的变量。
- 指针:可以在声明时不立即初始化,可以在任何时候被赋予任何有效的地址。
- 指向的修改:
- 引用:一旦引用被绑定到一个变量,它就不能被重新绑定到另一个变量。引用的指向是固定的。
- 指针:可以随时改变其指向的地址,指向任何其他有效的内存位置。
- 取址操作:
- 引用:在代码中对引用进行取址操作,得到的是它所绑定的变量的地址。这是因为引用本质上是变量的一个别名,所以取址实际上是获取了变量的地址。
- 指针:对指针进行取址操作,得到的是指针变量本身存储的地址,即指针变量的内存地址。
引用的使用场景
引用作为函数的参数
引用在 C++ 中是一种非常有用的机制,它允许函数直接操作传入的变量,而不是它们的副本。这不仅提高了效率,还增强了代码的可读性。以下是使用引用来交换两个整数变量值的示例:
// 值传递
// 形参初始化实际是复制的过程
// 函数体中操作的是实参的副本
// 不会改变实参
void swap(int x, int y) {
int temp = x;
x = y;
y = temp;
}
// 地址传递(指针传递)
// 不会让变量本身发生复制
void swap2(int *px, int *py) {
int temp = *px;
*px = *py;
*py = temp;
}
// 引用传递,也不会让变量本身发生复制
// 形参初始化过程
// int & x = a;
// int & y = b;
// 在函数体中对 x,y 进行操作,a 和 b 会随之改变
void swap3(int &x, int &y) {
int temp = x;
x = y;
y = temp;
}
// 希望确保 print 函数不会修改实参的值
// 如果采用值传递,虽然也能确保,但是有复制过程
// 若参数是对象,可能占内存较大,复制的开销是不必要的
//
// 如果采用普通的引用传递,不会复制,
// 但是无法确保实参不会改变
//
// 引用底层本身是 int * const
// 现在要增加的效果是不能通过引用修改其绑定的本体的值
// 可以使用常引用 const int & x 作为函数的形参
void print(const int &x, const int &y) {
/* x = 1; */
cout << "x:" << x << endl;
cout << "y:" << y << endl;
}
void test(){
int a = 10, b = 20;
print(a, b);
cout << "a:" << a << endl;
}
参数传递的方式包括值传递、指针传递和引用传递。
- 值传递:
- 形参是实参的副本,修改形参不会影响实参。
- 适用于小型数据或不需要修改实参的场景。
- 指针传递:
- 形参是实参地址的副本,通过解引用可以直接修改实参。
- 需要处理指针,可能增加代码复杂性。
- 引用传递:
- 形参是实参的别名,直接操作实参。
- 代码简洁,易于理解,且避免了数据复制。
使用常引用(const int &
)作为函数参数可以防止函数内部修改传入的变量,同时避免了复制,适用于以下情况:
- 不修改值:确保函数不会改变传入参数的值。
- 避免复制:对于大型数据结构,避免不必要的复制开销。
void printValue(const int &value) {
Value = 100; // 错误
std::cout << "Value: " << value << std::endl;
}
int main() {
int a = 10;
printValue(a); // 正确调用,不会修改a的值
return 0;
}
在这个示例中,printValue
函数接收一个常引用作为参数,确保了函数内部不会修改 value
的值,同时避免了复制 a
的开销。
引用作为函数的返回值
引用作为函数返回值是一种有效的方法,可以避免不必要的数据复制,从而提高程序的效率。然而,使用引用作为返回值时必须确保返回的变量在函数返回后仍然存在。
int gNum = 100;
int func1() {
return gNum;//return 时会发生复制
}
int &func2() {
return gNum;//return 时并不会发生复制
}
void test0() {
// func1 的返回值是 gNum 的副本
/* &func1();//error */
cout << func1() << endl;
// func2 的返回值是绑定到 gNum 的一个匿名的引用
cout << &gNum << endl;
cout << &func2() << endl;
cout << func2() << endl;
++func2();
cout << gNum << endl;
}
// func3 的返回值实际是一个悬空引用
// 所以如果函数返回类型为引用
// 要确保其绑定的本体生命周期比函数更长
/* int & func3(){ */
/* int num = 100; */
/* return num; */
/* } */
int &func4() {
int *p = new int(10);
return *p;
}
int main() {
/* cout << func4() << endl; */
/* cout << &func4() << endl; */
/* delete &func4(); */
int &ref = func4();
int a = ref + 1;
cout << a << endl;
cout << ++ref << endl;
delete &ref;
// 执行 delete 之后,ref 成了悬空引用
/* cout << ref << endl; */
}
注意事项:
- 避免返回局部变量的引用:
- 局部变量在函数返回后会被销毁,返回其引用会导致未定义行为。
- 以下是一个错误示例:
int &func() { int number = 1; return number; // 错误:返回局部变量的引用 }
- 谨慎返回堆空间变量的引用:
- 返回堆空间变量的引用可能导致内存泄漏,因为调用者可能忘记释放内存。
- 以下是一个错误示例:
int &func() { int *pint = new int(1); return *pint; // 错误:返回堆空间变量的引用 } void test() { int a = 2, b = 4; int c = a + func() + b; // 内存泄漏 }
建议:
- 使用静态变量:如果函数确实需要返回一个引用,可以考虑使用静态变量,这样变量的生命周期会持续到程序结束。
static int &func() { static int number = 1; return number; }
- 返回值拷贝:如果返回引用可能导致问题,可以考虑返回值的拷贝,虽然这会增加一定的开销。
int func() { int number = 1; return number; }
- 使用智能指针:如果需要返回动态分配的内存,可以考虑使用智能指针来管理内存,避免内存泄漏。
#include <memory> std::unique_ptr<int> func() { return std::make_unique<int>(1); }
总结
引用的主要用途和优势
- 提高数据传递效率:
- 引用主要用于函数参数传递,以解决大块数据或复杂对象传递时的效率和空间问题。
- 通过引用传递,可以避免数据的复制,从而提高程序的运行效率。
- 安全性与效率的结合:
- 使用引用传递参数时,可以通过
const
关键字确保参数不被修改,从而增加了代码的安全性。 - 这种结合不仅提高了效率,还保证了数据的完整性。
- 使用引用传递参数时,可以通过
- 简化操作与提高可读性:
- 与指针相比,引用的使用更加直观和简单。引用操作直接对应于目标变量的操作,无需解引用。
- 引用的逻辑简单,使得代码更易于理解和维护。
引用与指针的比较
- 操作的直观性:
- 指针需要通过指针变量间接操作变量,增加了程序的复杂性。
- 引用则直接操作目标变量,逻辑上更简单,提高了代码的可读性。
- 底层实现:引用在底层实现上仍然是基于指针,但编译器封装了这一细节,使得程序员无需直接处理指针。
- 推荐使用:在可以用指针或引用解决的问题上,更推荐使用引用,因为它提供了更好的可读性和安全性。
强制转换
C 风格的类型转换
C 风格的类型转换在 C++ 中仍然可以使用,其基本格式如下:
TYPE a = (TYPE) EXPRESSION;
C 风格类型转换的缺点:
- 灵活性过高:C 风格的转换可以在任意类型之间进行,这可能导致不安全的转换,如将
const
指针转换为非const
指针。 - 难以查找和维护:C 风格的转换使用括号和类型标识符,这在 C++ 代码中非常常见,使得查找和维护转换代码变得困难。
C++为了克服这些缺点,引进了 4 个新的类型转换操作符:static_cast
,const_cast
,dynamic_cast
,reinterpret_cast
。
static_cast
static_cast
是 C++ 中最常用的类型转换操作符,它用于在相关类型之间进行转换,但不能用于运行时类型检查。以下是 static_cast
的使用形式:
目标类型 转换后的变量 = static_cast<目标类型>(要转换的变量);
基本类型转换
static_cast
可以用于基本数据类型之间的转换,例如将 int
转换为 float
:
int iNumber = 100;
float fNumber = static_cast<float>(iNumber);
指针类型转换
static_cast
也可以用于指针之间的转换,例如将 void*
指针转换成其他类型的指针:
void *pVoid = malloc(sizeof(int));
int *pInt = static_cast<int*>(pVoid);
*pInt = 1;
限制
static_cast
不能完成任意两个指针类型间的转换,特别是不能将一个类型的指针转换为不相关的类型的指针,因为这可能导致未定义行为:
int iNumber = 1;
int *pInt = &iNumber;
float *pFloat = static_cast<float *>(pInt); // 错误:不能将 int* 转换为 float*
优势
- 安全性:
static_cast
不允许非法的转换发生,因为它在编译时进行检查。 - 可读性:使用
static_cast
可以提高代码的可读性,因为它明确表示了类型转换的意图。 - 易于查找:与 C 风格的转换相比,
static_cast
更容易在代码中查找和识别。
const_cast
const_cast
用于修改类型的 const
或 volatile
属性。它主要用于以下情况:
- 将常量指针转换为非常量指针:
指向常量的指针可以被转换为普通指针,但仍然指向原来的对象。
const int number = 100; // int *pInt = &number; // 错误 int *pInt2 = const_cast<int *>(&number);
- 将常量引用转换为非常量引用:
常量引用可以被转换为非常量引用,但仍然指向原来的对象。
const int &numberRef = number; int &numberRef2 = const_cast<int &>(numberRef);
const_cast
通常用于修改 const
属性,但应谨慎使用,以避免违反 const
正确性。基本不用于其他类型的转换。
dynamic_cast
该运算符主要用于基类和派生类间的转换,尤其是向下转型的用法中。
reinterpret_cast
reinterpret_cast
是 C++ 中功能最强大的类型转换操作符,它允许进行以下类型的转换:
- 指针(或引用)类型之间的转换:
- 可以将任何指针类型转换为任何其他指针类型,包括将指针转换为足够大的整数类型,以及反过来。
- 指针与整数之间的转换:
- 可以将指针转换为整数,或者将整数转换为指针。
使用注意事项:
reinterpret_cast
的使用需要非常小心,因为它可以绕过类型系统的保护,导致未定义行为。- 最正确的使用方式是将转换后的类型值转换回其原始类型。这种来回转换可以确保类型的一致性和安全性。
- 错误的使用
reinterpret_cast
可能导致程序崩溃或数据损坏,因为它允许进行几乎任意的类型转换。
函数重载
在 C++ 中,函数重载(Function Overloading)是一种允许多个函数具有相同名称,但参数列表不同的特性。这使得程序员可以使用相同的函数名来执行相似的操作,但针对不同类型的数据。
重载函数的定义
- 相同函数名:重载函数具有相同的函数名。
- 不同参数列表:每个重载函数的参数类型、数量或顺序必须至少有一个不同。
重载函数的优势
- 减少函数名数量:通过重载,可以减少创建大量类似名称的函数,使代码更加简洁。
- 提高可读性:使用相同的函数名可以提高代码的可读性,因为函数的行为更容易被理解。
- 增加灵活性:允许程序员为不同类型的数据定义相同的操作,增加了代码的灵活性。
int add(int x, int y) {
cout << "函数一" << endl;
return x + y;
}
double add(double x, double y) {
cout << "函数二" << endl;
return x + y;
}
double add(double x, int y) {
cout << "函数三" << endl;
return x + y;
}
double add(int x, double y) {
cout << "函数四" << endl;
return x + y;
}
int add(int x, int y, int z) {
cout << "函数五" << endl;
return x + y + z;
}
函数重载的规则
在 C++ 中,函数重载允许在同一作用域内定义多个具有相同名称但参数列表不同的函数。参数列表的不同可以包括:
- 参数的数量:函数接受不同数量的参数。
- 参数的类型:即使参数数量相同,参数的类型也可以不同。
- 参数的顺序:参数的顺序也会影响重载决策。
重载的决策不包括 返回类型:仅当返回类型不同时,不能构成重载。如果两个函数的参数列表完全相同,但返回类型不同,它们不会被视为重载,而是两个不同的函数。
int add(int x, int y){
cout << "函数一" << endl;
return x + y;
}
double add(double x, double y){
cout << "函数二" << endl;
return x + y;
}
// 错误:仅当返回类型不同时,不能构成重载
/* int add(double x, double y){ */
/* cout << "函数三" << endl; */
/* return x + y; */
/* } */
函数重载的实现原理
在 C++ 中,当函数重载时,编译器会对函数名进行改编,以确保每个函数都有一个唯一的标识符。这个过程通常涉及以下信息:
- 函数名:原始的函数名称。
- 参数类型:函数接受的参数类型。
- 参数顺序:参数在函数声明中的顺序。
- 参数数量:函数接受的参数总数。
名字改编的作用
- 确保唯一性:改编后的名称是唯一的,即使在不同的作用域中存在同名函数,只要参数列表不同,编译器也能区分它们。
- 支持链接:在大型项目中,名字改编确保了不同编译单元中的同名函数可以被正确链接。
函数重载的优缺点
优点:
- 代码简洁:可以使用相同的函数名来处理不同类型的数据,减少了代码的冗余。
- 提高可读性:使得代码更加直观,易于理解和维护。
缺点:
- 编译器效率:编译器需要额外处理名字改编,可能会稍微降低编译效率。
- 调用歧义:在某些情况下,重载函数的调用可能不够明确,需要程序员提供更多的信息来消除歧义。
extern C
在 C++ 中,函数名在编译时可能会经过名字改编(Name Mangling),这是为了支持函数重载。然而,C 语言不支持重载,因此 C 编译器不会对函数名进行改编。为了确保 C++ 编译器在处理 C 代码或与 C 代码交互时不进行名字改编,可以使用 extern "C"
。
修饰单个函数:当只需要确保某个函数按照 C 语言的链接约定编译时,可以直接在函数声明前使用 extern "C"
。
extern "C" void func() {
// 函数实现
}
修饰多个函数或代码块:当需要将多个函数或整个代码块按照 C 语言的链接约定编译时,可以使用大括号 {}
包围这些函数或代码。
extern "C" {
void func1() {
// 函数实现
}
void func2() {
// 函数实现
}
}
使用 C 语言库:当 C++ 程序需要调用 C 语言库中的函数时,应该使用 extern "C"
来确保函数名不会被改编,从而保证链接正确。
默认参数
默认参数的目的
在 C++ 中,可以在函数声明时为参数指定默认值。如果在函数调用时没有提供对应的实参,函数会使用这些默认值。
// 为参数 x 和 y 指定默认值 0
void func(int x = 0, int y = 0) {
cout << "x = " << x << endl;
cout << "y = " << y << endl;
}
void test() {
func(24, 30); // 调用时提供两个参数
func(100); // 只提供第一个参数,y 使用默认值
func(); // 没有提供任何参数,x 和 y 都使用默认值
}
缺省调用:在调用函数时,如果某些参数没有提供实参,函数会使用参数的默认值。
传入的参数优先级高于默认参数:如果在函数调用时提供了某个参数的实参,即使后面还有未提供的参数,后面的参数也会使用默认值。
注意事项:
- 默认参数应在函数声明时定义:不能在函数定义时才指定默认参数。
- 默认参数只能用于函数声明:在 C++ 中,函数的定义(实现)不能包含默认参数,必须与声明完全一致。
默认参数的声明
在 C++ 中,函数的默认参数通常在函数的声明中提供。这样做的原因是:
- 编译器在编译时已经知道默认值:这使得编译器在处理函数调用时能够正确地识别哪些参数使用了默认值。
- 避免在函数定义中重复默认值:在声明中设置默认值后,定义中不需要再次指定。
声明中设置默认值:声明中设置默认值是推荐的做法,因为它允许编译器在处理函数调用之前就知道所有必要的信息。
定义中设置默认值:如果在定义中设置默认值,函数的定义必须在任何函数调用之前。这是因为编译器需要在处理调用时知道参数的默认值。
正确的示例:在声明中设置默认值
// 声明中设置默认值
void func(int x = 0, int y = 0);
void test() {
func(1, 2);
func(10); // y 使用默认值
func(); // x 和 y 都使用默认值
}
// 定义
void func(int x, int y) {
cout << x + y << endl;
}
错误的示例:在定义中设置默认值
// 声明
void func(int x, int y);
void test() {
func(); // 错误:在调用时编译器不知道默认值
}
// 定义中设置默认值
void func(int x = 0, int y = 0) {
cout << x + y << endl;
}
注意事项
- 不要在声明和定义中都设置默认值:这会导致编译错误,因为编译器会看到两个不同的默认值设置。
- 确保定义与声明匹配:如果声明中设置了默认值,定义中也必须使用相同的默认值。
默认参数的顺序规定
在 C++ 中,如果一个函数有多个参数,并且其中一些参数有默认值,那么这些默认参数必须按照从右到左的顺序定义。这是因为在调用函数时,如果省略了一些参数,编译器需要能够从左到右匹配剩余的参数。
正确的默认参数定义:
// 正确的默认参数定义
void func(int a, int b, int c = 0, int d = 0) {
cout << "a = " << a << ", b = " << b << ", c = " << c << ", d = " << d << endl;
}
void test() {
func(1, 2); // c 和 d 使用默认值
func(1, 2, 3); // d 使用默认值
func(1, 2, 3, 4); // 所有参数都指定了值
}
错误的默认参数定义
// 错误的默认参数定义
void func(int a = 1, int b, int c = 0, int d) { // 错误:b 在有默认值的参数之前
cout << "a = " << a << ", b = " << b << ", c = " << c << ", d = " << d << endl;
}
int main() {
// func(1, 2); // 这会导致编译错误,因为 b 没有默认值,而 c 有默认值
return 0;
}
从左到右匹配:在调用函数时,实参是从左到右匹配形参的。如果某个参数有默认值,那么它后面的所有参数也必须有默认值,以确保可以正确地省略参数。
默认参数与函数重载
使用默认参数可以将多个重载的函数合并为一个,减少代码的冗余。例如,以下三个重载函数:
void func();
void func(int x);
void func(int x, int y);
可以合并为一个带有默认参数的函数:
void func(int x = 0, int y = 0);
这样,通过省略参数,可以灵活地调用函数。
在使用默认参数时,必须确保不会引起调用的二义性。如果一组重载函数(可能带有默认参数)都允许相同实参个数的调用,将会引起调用的二义性。
正确的示例:合并重载函数
// 合并重载函数
void func(int x = 0, int y = 0) {
cout << "x = " << x << ", y = " << y << endl;
}
void test() {
func(); // 使用所有默认值
func(10); // y 使用默认值
func(10, 20); // 所有参数都指定了值
}
错误的示例:引起二义性
// 引起二义性的重载
void func(int x);
void func(int x, int y = 0);
func(1); // 错误:无法确定调用的是哪种形式的 func
bool
类型
- 逻辑值:
bool
类型有两个可能的值:true
和false
。
- 隐式转换:
- 任何数字或指针值都可以隐式转换为
bool
类型。 - 非零值(包括所有非空指针)转换为
true
,零值(包括空指针)转换为false
。
- 任何数字或指针值都可以隐式转换为
- 字面值转换:
true
和false
可以隐式转换为int
类型,其中true
转换为 1,false
转换为 0。
- 存储大小:
bool
类型的变量通常占用 1 个字节的空间,但实际大小可能取决于编译器的实现。
示例代码
#include <iostream>
using namespace std;
int main() {
// 隐式转换 true 和 false 为 int
int x = true; // x 的值为 1
int y = false; // y 的值为 0
// 隐式转换数字和指针为 bool
bool b1 = -100; // true
bool b2 = 100; // true
bool b3 = 0; // false
bool b4 = 1; // true
bool b5 = true; // true
bool b6 = false; // false
// 显示 bool 变量的大小
int size = sizeof(bool);
cout << "Size of bool: " << size << " bytes" << endl; // Size of bool: 1 bytes
// 隐式转换演示
cout << "b1 (-100) as bool: " << bool(b1) << " (true or false)" << endl; // b1 (-100) as bool: 1 (true or false)
cout << "b2 (100) as bool: " << bool(b2) << " (true or false)" << endl; // b2 (100) as bool: 1 (true or false)
cout << "b3 (0) as bool: " << bool(b3) << " (true or false)" << endl; // b3 (0) as bool: 0 (true or false)
cout << "b4 (1) as bool: " << bool(b4) << " (true or false)" << endl; // b4 (1) as bool: 1 (true or false)
cout << "b5 (true) as bool: " << bool(b5) << " (true or false)" << endl; // b5 (true) as bool: 1 (true or false)
cout << "b6 (false) as bool: " << bool(b6) << " (true or false)" << endl; // b6 (false) as bool: 0 (true or false)
return 0;
}
注意事项
- 隐式转换:虽然
bool
类型可以与其他类型隐式转换,但应谨慎使用,以避免逻辑错误。 - 条件表达式:在条件表达式中,如
if
或while
语句中,任何非bool
类型的表达式都会转换为bool
类型。 - 存储大小:尽管
bool
变量通常占用 1 个字节,但 C++ 标准没有明确规定其大小,因此依赖于编译器和平台。
inline 函数
普通函数
在 C++编程中,确定两个整数中的最大值通常可以通过定义一个专门的函数来实现,如下所示:
int max(int x, int y)
{
return (x > y) ? x : y;
}
尽管一些人可能会认为,直接使用三元运算符来获取两个数中的最大值更为便捷,但定义一个函数有其独特的优势:
- 提高可读性:调用
max
函数比阅读并理解一个等价的条件表达式更为直观,尤其是在函数返回值被用于更复杂的表达式中时。 - 便于维护:如果需要对最大值的获取逻辑进行修改,只需更改函数内部的实现,而不必在代码中搜索并修改每一个等价的条件表达式。
- 确保一致性:使用函数可以保证在不同地方调用时行为的一致性,这对于测试和验证代码的正确性至关重要。
- 代码重用:定义函数意味着可以在不同的程序中重用相同的代码,而无需重复编写相同的逻辑。
然而,将逻辑封装在函数中也有其潜在的不足之处:函数调用通常比直接执行等价表达式要消耗更多的时间。
在大多数计算机系统中,函数调用涉及到一系列的开销:保存寄存器状态、复制参数、跳转到函数的内存地址执行等。对于如此简单的操作,使用函数可能会引入不必要的性能开销。当然,在 C++ 中,我们可以通过 内联函数(inline functions)来减少这种开销,因为编译器会尝试将内联函数的代码直接插入到调用点,从而避免函数调用的额外开销。
此外,对于 C 语言,还可以考虑使用 宏定义(macro definitions)来实现类似的功能,并通过编译器优化来提高执行效率。在 C++ 中,宏定义通常不推荐用于此类逻辑,因为它们不提供类型检查,且可能导致预处理时的意外行为。相反,内联函数提供了一个更安全且类型安全的替代方案。
宏定义
在 C 语言中,宏定义是一种提高代码执行效率的方法。宏定义通过预处理器在编译前将代码片段替换为指定的表达式,避免了函数调用的开销,如参数传递、栈操作和返回指令等。
然而,宏定义存在一些潜在的问题,如边际效应,这可能导致意料之外的结果。例如:
#define MAX(a, b) (a) > (b) ? (a) : (b)
void test1(){
int res = MAX(20, 10) + 20; // res 的值是多少?
int res2 = MAX(10, 20) + 20; // res2 的值是多少?
}
// 预处理器将扩展为: result = (i) > (j) ? (i) : (j) + 20
为了避免这类问题,宏定义可以修改为:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
尽管如此,宏定义仍然可能引入其他问题,如:
int i = 4, j = 3;
int res = MAX(i++, j);
cout << res << endl; // res的值是多少?
cout << i << endl; // i的值是多少?
// 预处理器将扩展为: result = ((i++) > (j) ? (i++) : (j));
宏定义的替换机制不进行安全检查,可能会导致复杂表达式的错误。
在 C++ 中,内联函数提供了一种更安全且高效的替代方案。内联函数通过编译器优化,减少了函数调用的开销,同时保持了代码的可读性和可维护性。
与宏定义不同,内联函数支持调试。在调试模式下,内联函数不会真正内联,而是像普通函数一样生成包含调试信息的代码。这使得开发者能够在调试过程中跟踪函数调用和变量状态。
内联函数
在 C++编程中,内联函数是一种优化技术,旨在减少函数调用的开销,从而提高程序的执行效率。通过在函数定义前添加 inline
关键字,开发者向编译器提出将该函数内联展开的建议。
内联函数的工作机制:
- 内联展开:当编译器接受内联建议时,它会在编译阶段将函数的定义直接替换到函数调用的位置。这种替换可以减少函数调用的开销,如参数传递、栈操作和返回指令等。
-
符号表的使用:内联函数的声明和定义被存储在编译器的符号表中。在调用内联函数时,编译器会进行类型安全检查和自动类型转换,确保调用的正确性。
-
调试支持:与宏定义不同,内联函数在调试模式下不会真正展开。编译器会为内联函数生成包含调试信息的可执行代码,使得开发者能够在调试过程中跟踪函数调用和变量状态。
内联函数的使用建议:
- 内联建议的非强制性:
inline
关键字只是一个建议,编译器有权决定是否进行内联展开。编译器会根据函数的复杂度、调用频率和优化策略来决定是否内联。 -
编译时展开:内联展开发生在编译时,这与预处理器的宏展开不同。编译器在内联展开时会执行类型安全检查和自动类型转换,确保代码的正确性。
-
代码膨胀的考虑:如果函数体过长或包含复杂的控制结构(如循环),则不建议使用内联。内联这样的函数可能会导致代码膨胀,从而增加程序的大小,降低缓存命中率,反而降低程序的性能。
inline int add(int a, int b) {
return a + b;
}
void test() {
int result = add(5, 3); // 调用add函数,编译器可能会进行内联展开
}
在上述示例中,add
函数是一个内联函数,它在调用时可能会被编译器内联展开,以提高执行效率。然而,如果 add
函数的实现变得复杂,或者调用频率不高,编译器可能会选择不进行内联展开。
在 C++中使用内联函数时,需要注意以下几个关键点以确保代码的正确性和效率:
- 内联函数声明与实现分离:内联函数可以在头文件中声明和定义,也可以在源文件中定义。但是,如果声明和定义分离,编译器可能无法在调用点找到函数的定义,从而无法进行内联展开。
inline int multiply(int x, int y); int multiply(int x, int y) { return x + y; } // 按照普通函数的形式调用 void test() { cout << multiply(2, 5) << endl; } //////////////////////////////////// inline int multiply(int x, int y); inline int multiply(int x, int y) { return x + y; } // 按照内联函数的形式调用 void test() { cout << multiply(2, 5) << endl; } //////////////////////////////////// int multiply(int x, int y); inline int multiply(int x, int y) { return x + y; } // 按照内联函数的形式调用 void test() { cout << multiply(2, 5) << endl; }
- 内联函数的定义必须在头文件中:如果内联函数的声明在头文件中,那么其定义也必须在头文件中,以确保在每个调用点都能被正确内联展开。这是因为编译器在编译时需要访问到函数的完整定义来进行替换。
inline void foo(int x, int y); //该语句在头文件中 void foo(int x, int y) {//实现在 .cpp 文件中 //... } //foo 函数不能成为内联函数
- 内联函数的定义位置
-
头文件中的内联定义:将内联函数的定义放在头文件中可以确保所有包含该头文件的源文件都能使用相同的函数定义,从而实现内联展开。
-
源文件中的内联定义:如果内联函数定义在源文件中,那么只有该源文件中的调用可以内联,其他源文件则无法内联该函数。
- 内联函数的使用注意事项
-
代码膨胀:内联函数虽然可以减少函数调用的开销,但会增加代码量,可能导致内存占用增加,特别是在函数体较大或被频繁调用时。
-
执行效率:如果函数体内的执行时间远大于函数调用的开销,内联可能不会带来显著的性能提升。
-
编译器的决策:编译器会根据函数的复杂度和调用频率来决定是否内联。复杂的函数或调用频率不高的函数可能不会被内联。
- 内联函数的适用场景
-
简单函数:对于逻辑简单、执行速度快的函数,内联可以显著提高执行效率。
-
频繁调用的函数:对于在程序中被频繁调用的函数,内联可以减少重复的函数调用开销。
- 编译器的优化:现代编译器通常具有智能的内联决策机制,能够自动决定哪些函数适合内联,哪些不适合。
三种函数之间的比较
宏函数
优点:
- 执行效率高:宏函数通过预处理器进行文本替换,不涉及函数调用的开销,适合用于简单的、频繁执行的代码片段。
- 简单替换:对于非常简短的操作,如条件判断或简单的算术运算,宏可以提供快速的执行。
缺点:
- 缺乏类型检查:宏在预处理阶段进行文本替换,不进行类型安全检查,可能导致运行时错误。
- 易于出错:宏的替换可能导致意外的边际效应,如操作符优先级问题,使得代码难以维护和理解。
- 不可调试:宏展开后的代码在调试时难以追踪,因为它们在编译前就已经被替换,失去了原有的函数调用结构。
普通函数
优点:
- 类型安全:普通函数在编译时进行类型检查,确保参数和返回值的类型正确。
- 易于调试:函数调用在调试时清晰可见,便于追踪和分析程序的执行流程。
缺点:
- 调用开销:每次函数调用都会涉及参数传递、栈操作等开销,可能影响程序性能,尤其是在频繁调用的情况下。
内联函数
优点:
- 效率与安全兼备:内联函数在编译时展开,减少了函数调用的开销,同时保留了类型检查和调试信息。
- 代码优化:内联函数可以减少小函数调用的开销,同时保持代码的可读性和可维护性。
缺点:
- 代码膨胀:如果内联函数体较大,可能会导致代码膨胀,增加程序的内存占用。
- 编译器决策:内联的决定权在编译器,编译器可能基于性能和代码大小的权衡决定是否内联。
在 C++ 中,推荐尽可能使用内联函数来取代宏函数,因为它们提供了更好的类型安全性和调试支持,同时还能保持较高的执行效率。然而,对于复杂的函数或在性能敏感的应用中,应仔细考虑是否使用内联,以避免不必要的代码膨胀和性能下降。普通函数则适用于需要频繁调用且逻辑复杂的场景,尽管它们会带来一定的调用开销。
异常处理
在 C++中,异常处理是一种强大的机制,用于处理程序运行时发生的意外情况。它允许程序在遇到错误时,优雅地处理问题,而不是直接崩溃。C++异常处理主要涉及三个关键字:try
、catch
和 throw
。
当程序检测到错误条件时,可以使用 throw
语句来抛出一个异常。异常可以是任何类型的表达式,包括基本数据类型、对象或字符串。
double division(double x, double y) {
if (y == 0) {
throw "Division by zero condition!";
}
return x / y;
}
在这个例子中,如果尝试除以零,程序将抛出一个字符串异常。
try
块用于包围可能抛出异常的代码。catch
块用于捕获并处理这些异常。可以有多个 catch
块来处理不同类型的异常。
void test0() {
double x = 100, y = 0;
try {
cout << division(x, y) << endl;
} catch (const char* msg) { // 捕获char* 类型的异常
cout << "Error: " << msg << endl;
} catch (...) { // 捕获所有类型的异常
cout << "An unknown error occurred." << endl;
}
}
在这个例子中,如果 division
函数抛出异常,try-catch
块将捕获该异常并处理它。
注意事项:
- 异常类型匹配:
catch
块捕获的是异常的类型,而不是具体的信息。例如,catch (const char* msg)
只能捕获char*
类型的异常。 - 异常的传递:如果一个
catch
块没有捕获到异常,控制权将传递给下一个catch
块。 - 异常的安全性:在抛出和捕获异常时,应确保程序的安全性和数据的完整性。
异常处理的建议:
- 精确捕获:尽量使用具体的异常类型,而不是使用
catch (...)
,这可以提供更精确的错误处理。 - 资源管理:在异常可能发生的地方,确保资源(如文件句柄、网络连接等)得到正确管理,例如使用 RAII(资源获取即初始化)技术。
- 异常的文档:在函数或类的文档中明确指出可能抛出的异常,以便调用者可以正确处理。
内存布局
以 32 位系统为例,一个进程在执行时,能够访问的空间是 虚拟地址空间。理论上为 2^{32},即 4G,有 1G 左右的空间是内核态,剩下的 3G 左右的空间是用户态。从高地址到低地址可以分为五个区域:
- 栈区(Stack):
- 由操作系统自动管理。
- 用于存储局部变量、函数参数、返回地址等。
- 通常从高地址向低地址增长。
- 每个线程拥有自己的栈空间,以支持多线程环境下的数据隔离。
- 堆区(Heap):
- 由程序员通过
malloc
、new
等函数手动管理。 - 用于动态内存分配,如创建动态数组、对象等。
- 从低地址向高地址增长。
- 堆区的大小通常由操作系统限制,但可以通过程序调整。
- 由程序员通过
- 全局/静态区(Data Segment):
- 存储全局变量和静态变量。
- 包括初始化数据段和未初始化数据段(BSS 段)。
- 全局变量在程序启动时分配,生命周期贯穿整个程序。
- 文字常量区(Text Segment/Read-Only Data Segment):
- 存储程序中使用的只读数据,如字符串常量。
- 例如,
const char* p = "hello";
中的 “hello” 存储在此区域。 - 此区域在程序运行期间通常不可修改。
- 程序代码区(Text Segment):
- 存储程序的二进制指令。
- 此区域为只读,以防止程序运行时被修改。
- 包含函数定义、操作指令等。
内存管理注意事项:
- 栈溢出:如果栈空间使用过多,可能会导致栈溢出,这可能覆盖其他内存区域,引发安全问题。
- 堆溢出:不当的内存管理可能导致堆溢出,同样可能破坏内存数据,引发安全问题。
- 内存泄漏:未正确释放已分配的堆内存,可能导致内存泄漏,随着时间的推移,可能会耗尽系统资源。
#include <iostream>
using std::cout;
using std::endl;
namespace wd {
int wNum = 12;
}
int gNum = 100;
const int cNum = 1;
void test0() {
int num1 = 10;
int num2 = 20;
const int num3 = 30;
cout << "&num3:" << &num3 << endl; // &num3:0x7ffd36174da4
cout << "&num2:" << &num2 << endl; // &num2:0x7ffd36174da0
cout << "&num1:" << &num1 << endl; // &num1:0x7ffd36174d9c
int *p = new int(10);
cout << "p:" << p << endl; // p:0x556afaa742c0
delete p;
p = nullptr;
static int sNum = 1000;
cout << "&sNum:" << &sNum << endl; // &sNum:0x556afa45e018
cout << "&gNum:" << &gNum << endl; // &gNum:0x556afa45e014
cout << "&wNum:" << &wd::wNum << endl; // &wNum:0x556afa45e010
cout << endl;
const char *pstr = "hello";
cout << pstr << endl; // hello
cout << &"hello" << endl; // 0x556afa45c035
printf("%p\n", pstr); // 0x556afa45c035
cout << static_cast<void *>(const_cast<char *>(pstr)) << endl; // 0x556afa45c035
cout << "&cNum:" << &cNum << endl; // &cNum:0x556afa45c004
printf("%p\n", &test0); // 0x556afa45b249
}
int main(void) {
test0();
printf("%p\n", main); // 0x556afa45b58e
return 0;
}
C 风格字符串
C 风格字符串是 C 和 C++语言中处理文本数据的一种基本方式。它们是由字符组成的数组,以空字符('\0'
,值为 0 的字符)作为字符串的结束标志。
使用字符数组存储字符串时,需要在数组的最后一个元素位置留出空间放置空字符,以标识字符串的结束。
char str1[] = {'h', 'e', 'l', 'l', 'o', '\0'}
char str2[] = "World!";
当使用指针来存储字符串时,通常定义为 const char *
类型,这样可以确保字符串内容不会被修改,增强代码的安全性。
const char *str = "Hello, World!";
// 这里的字符串是只读的,str指针指向一个只读内存区域
字符串常量通常存放在程序的只读数据段。如果使用 char *
指针指向字符串常量,理论上存在修改字符串内容的风险,尽管大多数现代编译器会禁止这么做。
在 C++中,输出流运算符(<<
)对于 char *
类型有重载,可以直接输出字符串的内容而不是地址。
#include <iostream>
#include <cstring>
int main() {
const char *str = "Hello, World!";
std::cout << str << std::endl; // 输出字符串内容
return 0;
}
注意事项
- 安全性:使用
const char *
而不是char *
来避免意外修改字符串常量。 - 内存管理:C 风格字符串不涉及动态内存分配,但如果你使用
strdup
等函数复制字符串,需要记得释放内存。 - 编码问题:C 风格字符串通常使用 ASCII 或 UTF-8 编码,处理非 ASCII 字符时需要注意编码问题。