林锐《高质量程序设计指南C/C++》笔记01:基本数据类型、类型转换

笔者有幸得到一次和行业前辈交流的机会,前辈也分享了他对于职业规划、人生发展的理解和感悟,笔者受益匪浅。另外由于笔者主要方向是C/C++,刚入门不久,前辈也是针对能力提升方面推荐了很多课程和书籍,其中就有这本 《高质量程序设计指南C/C++》作者林锐,第三版,非常感谢前辈的建议。

笔者先大致浏览了一遍该书,发现有很多平时开发或学习中没有注意到的小细节,因此新开一帖,作为自用的学习笔记。本系列由于是读书笔记,因此主要会记录笔者本人平时没有留意的细节问题,并针对这些问题会提出一些额外问题和分析,如底层实现和延伸思考。

自己也是刚入门不久,可能会有很多错误,欢迎大家一起学习,不吝赐教,有任何问题可以评论私信。


该系列:
林锐《高质量程序设计指南C/C++》笔记02
林锐《高质量程序设计指南C/C++》笔记03



第四章

一、作用域

原文:“C语言中,所有函数不是局部于编译单元(文件作用域)的static函数,就是具有extern连接类型和global作用域的全局函数,因此除了两个分别位于不同编译单元中的 static 函数可以同名外,全局函数是不能同名的;全局变量也是同样的道理。”

C++有哪几种作用域:

  1. 文件作用域:所有函数外部使用static关键字定义的变量的作用域,它们只能在该文件内部访问,不能被其他文件访问。这样即使两个不同的源文件都定义了相同的静态全局变量,他们也是不同的变量。 文件作用域变量保存在内存的全局存储区中,占用静态的存储单元。
  2. 全局作用域:所有函数外部定义的变量的作用域,它们在整个程序的生命周期内都是有效的,可以被任何函数访问。全局作用域变量也保存在内存的全局存储区中,占用静态的存储单元。
  3. 局部作用域:在函数或代码块内部定义的变量具有局部作用域,它们只能在函数或代码块内部访问。局部变量在函数或代码块每次被执行时被创建,在函数或代码块执行完后被销毁。局部变量保存在栈中,占用动态的存储单元。
  4. 语句作用域:在语句内部定义的变量具有语句作用域,它们只能在语句内部访问,比如for循环,switch语句中使用的变量。语句作用域变量在语句每次被执行时被创建,在语句执行完后被销毁。语句作用域变量也保存在栈中,占用动态的存储单元。
  5. 类作用域:在类内部定义的变量具有类作用域,它们可以被类的所有成员函数访问。类作用域变量的生命周期与类的生命周期相同。类作用域变量可以分为静态成员变量和非静态成员变量,静态成员变量保存在内存的全局存储区中,非静态成员变量保存在堆中
  6. 命名作用域:在命名空间内部定义的变量具有命名空间作用域,它们可以被同一命名空间内的其他变量或函数访问,也可以被其他命名空间或全局范围内使用命名空间限定符(::)来访问。命名空间作用域变量保存在内存的全局存储区中,占用静态的存储单元。

注意:

  1. 文件作用域和全局作用域的区别?
    • 访问权限文件作用域变量只能被同一文件内的函数访问,而全局作用域变量可以被任何文件内的函数访问。(static关键字的作用
    • 链接性:即是否可以被其他文件引用。文件作用域变量具有内部链接性,即只能在本文件内部链接,而全局作用域变量具有外部链接性,即可以在其他文件中使用extern关键字来引用(在本源文件中定义,在其他文件中使用 extern 关键字和变量名来声明变量,例如 extern int x;)。
  2. 在程序中,局部变量和全局变量的名称可以相同,但是在函数内,局部变量的值会覆盖全局变量的值
#include <iostream>
using namespace std;
 
// 全局变量声明
int g = 20;
 
int main ()
{
  // 局部变量声明
  int g = 10;
 
  cout << g; //输出为10
 
  return 0;
}
  1. 静态局部变量:被static修饰的局部变量是超出作用域就销毁呢,还是维持static持久性直到程序结束销毁呢?

    • 静态局部变量只在第一次执行到它的定义处时被初始化,以后再调用该函数时不再进行初始化。
    • 静态局部变量的值在函数调用结束后不会消失,而是保持原来的值,直到下一次调用该函数。
    • 静态局部变量的作用域仅限于定义它的函数内部,其他函数不能访问它
    • 静态局部变量存储在全局数据区,而不是栈区。
  2. 在一个编译单元中定义的全局变量的初始值不要依赖定义于另一个编译单元中的全局变量的初始值。
    请添加图片描述

参考:
C++ 变量作用域(菜鸟教程)
c++中的作用域(CSDN)
extern使用方法总结


二、函数重载

原文:“因此,如果不对它们进行重命名,就会导致连接二义性。在 C++中,重命名称为“Name-Mangling”(名字修饰或名字改编)。例如,在它们的前面分别添加所属各级作用域的名称(class、namespace 等)及重载函数的经过编码的参数信息(参数类型和个数等)作为前缀或者后缀,产生全局名字 Sample_1_foo@peh@1、Sample_1_foo@int@1、Sample_2_foo@pch@1 和 Sample_2_foo@int@1,这样就可以区分了。”

前面说到了C中函数或变量在不同作用域下的生命周期以及是否可以重名的问题,但是C++中引入了函数重载,这就意味着相同函数名的函数可以根据参数列表的不同(参数顺序,参数类型,参数数目等)进行区分

int Add(int a, int b)
{
	return a + b;
}

double Add(double a, double b)
{
	return a + b;
}

  1. 那原理是什么?

简单来说,C++中经过重载的函数具有自己独特的函数签名,比如上面两个函数经过反汇编后的函数签名分别为:

_Z3Addii
_Z3Adddd

看出区别了吧,在编译后,函数签名已经都不一样了,自然也就不冲突了。这就是为什么C++可以实现重名函数,但实际上编译后的函数签名是不一样的。

函数签名的命名方式为:_Z + 函数名长度 + 函数名 + 类型首字母的小写
前缀 _z 是GCC的规定,参数类型转换规则:int–>i,long–>l,char–>c,short–>s

此外,可以看到函数签名没有包含返回值类型,因此函数重载不能以返回值类型作为区分。

  1. C为什么没有函数重载?

    • C语言的编译器在编译时会对函数名进行简单的修饰,即在函数名前加上一个下划线,例如 int add(int a, int b) 会被修饰为 _add。这样的修饰规则不能区分不同的参数类型和个数,导致重载函数的名字冲突。
    • C语言的链接器在链接时会根据函数名在符号表中查找函数的地址,如果有多个重载函数,链接器无法确定调用哪个函数,导致链接错误。
  2. 那么程序是如何调用函数的呢?

    • 调用者(caller)将实参(arguments)按照一定的顺序压入栈中,然后执行call指令,将返回地址(return address)也压入栈中,然后跳转到被调用者(callee)的入口地址(entry address)。
    • 被调用者(callee)在执行函数体之前,需要建立自己的栈帧(stack frame),保存上一个函数的基址寄存器(base pointer register)的值,然后将栈顶寄存器(stack pointer register)的值赋给基址寄存器,作为自己的基址。如果需要,还要保存一些其他寄存器的值,以免被覆盖。然后根据局部变量和临时变量的大小,调整栈顶寄存器的值,为它们分配空间。
    • 被调用者(callee)执行函数体中的语句,可以通过基址寄存器加上偏移量来访问参数和局部变量。如果有返回值,通常会放在累加器寄存器(accumulator register)中。
    • 被调用者(callee)在返回之前,需要恢复自己修改过的寄存器的值,然后将基址寄存器的值赋给栈顶寄存器,回收自己的栈帧。然后执行ret指令,将栈顶的返回地址弹出,并跳转到该地址,将控制权交还给调用者(caller)。
    • 调用者(caller)在接收到控制权后,可以从累加器寄存器中取出返回值(如果有),然后根据参数的个数和类型,调整栈顶寄存器的值,弹出参数。如果需要,还要恢复自己保存过的寄存器的值。然后继续执行下一条语句。

参考:
【C++】函数重载的底层原理
C/C++ 函数调用是如何实现的?
C++函数调用过程深入分析
C++函数调用机制


三、数据类型

1、void

void 是“空”类型(无值型),意思是这种类型的大小无法确定。显然不存在 void 类型的对象,所以就不能声明 void 类型的对象或是将 sizeof()运算符用于 void类型,C++/C 语言不能对一个大小未知的对象直接操作。void 通常用来定义函数的返回类型、参数列表(无参)或者 void 指针。void 指针可以作为通用指针,因为它可以指向任何类型的对象。

注意:

  1. void 类型指针和NULL 指针值之间的区别:
    • void 类型指针是一个可以指向任意类型数据的指针,而NULL 指针值是一个特殊的常量,表示一个空的或者无效的指针。
    • NULL 是可以赋值给任何类型指针的值0,在C语言环境中它的类型为 void*,而在标准C++语言环境中由于允许从 0到任何指针类型的隐式转型,因此 NULL就是整数 0。
    • 一个void*类型的指针 是一个合法的指针,常用在函数参数中来传递一个函数与其调用者之间约定好类型的对象地址,如在线程函数中。而一个值等于 NULL 的指针虽也是一个合法的指针,但不是一个有效的指针。
    • void 类型指针可以进行赋值操作,即可以用任意类型的指针对 void 类型指针赋值,也可以用 void 类型指针对任意类型的指针赋值(需要强制类型转换)。例如,int *a; void *p; p=a; a=(int *)p; 这些都是合法的赋值语句。NULL 指针值也可以进行赋值操作,即可以用 NULL 给任意类型的指针赋值,也可以用任意类型的指针给 NULL 赋值(不需要强制类型转换)。例如,int *a; a=NULL; NULL=a; 这些都是合法的赋值语句。
    • 二者使用场景:void 类型指针通常用于在不确定指向什么类型数据的情况下,或者用于通用函数接口,例如用于传递函数指针给回调函数,或者用于动态分配内存并返回其地址。NULL 指针值用于检查指针是否有效,例如在动态内存分配失败时,通常会将指针设置为 NULL,然后在后续的代码中检查它是否为 NULL。

2、bool

虽然 bool 类型的变量只存在两种可能的值: true 和 false,按理说只需要一个 bit 就可以表示了。但是字节是内存编址的最小单位,而计算机从内存中提取一个变量的值是通过其地址进行的,所以一个 bool变量也占据1字节内存,即 sizeof(bool)等于1,浪费了7bit。


四、类型转换

1、隐式转换

由于派生类和基类之间的 is-a 关系,可以直接将派生类对象转换为基类对象,这虽然会发生内存截断,但是无论从内存访问还是从转换结果来说都是安全的。这得益于 C++的一个保证:派生类对象必须保证其基类子对象的完整性,即其中的基类子对象的内存映像必须和真正的基类对象的内存映像一致

类其实也是一种数据类型,也可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义。
向上转型(Upcasting):将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用。
相应地,将基类赋值给派生类称为向下转型(Downcasting)。

  • is-a 关系:派生类和基类之间的is-a关系表示派生类可以视为是基类的一种。这意味着派生类对象可以替代基类对象,因为派生类继承了基类的属性和行为。
  • 内存截断:内存截断是指当将派生类对象转换为基类对象时,派生类对象的内存空间会被截断,只保留基类对象所需的部分。这是因为派生类可能包含额外的成员变量和方法,但当将其转换为基类对象时,只能保留基类的成员。
  • 基类子对象的完整性:C++要求派生类对象必须保证其基类子对象的完整性。这意味着派生类对象的内存映像必须包含一个完整的基类对象。也就是说,派生类对象的内存中包含了基类对象的数据成员和方法,以确保从派生类对象转换为基类对象是安全的
  • 内存映像一致性:内存映像一致性表示派生类对象的内存布局必须与真正的基类对象的内存布局一致。这是为了确保在将派生类对象强制类型转换为基类对象时,不会发生数据损失或内存访问错误。

注意:

  1. 类的转换内存截断
    派生类到基类的向上转型,向上转型非常安全,可以由编译器自动完成;向下转型有风险,需要程序员手动干预。

假设有一个基类 A 和一个派生类 B,

class A {
public:
    int a;
    void f()};

class B : public A {
public:
    int b;
    void g()};

int main() {
    B b;  // 创建一个 B 类型的对象
    B* p = &b;
    // p 可以访问 B 类型的数据和函数
    p->b = 20;
    p->g();

    // 将 p 转换为 A 类型的指针 q,q 只能访问 A 类型的数据和函数
    A* q = p;
    q->a = 10;
    q->f();

    // 内存截断:q 无法访问 B 类型新增的数据和函数(B::b 和 B::g)

    // 向下转型:将 q 转换回 B 类型的指针 r
    B* r = dynamic_cast<B*>(q);

    if (r) {
        // r 可以访问 B 类型的数据和函数
        r->b = 30;
        r->g();
    } else {
        std::cout << "Downcast failed!" << std::endl;
    }
    return 0;
}

其中,A::a 和 A::f 是基类 A 的数据和函数,B::b 和 B::g 是派生类 B 新增的数据和函数。
如果有一个 B 类型的指针 p 指向一个 B 类型的对象 b,那么 p 可以访问 b 的所有数据和函数。
如果将 p 转换为 A 类型的指针 q,那么 q 只能访问 b 的基类部分的数据和函数,即 A::a 和 A::f,而不能访问 B::b 和 B::g。
这就是内存截断(memory slicing)的现象。但是,这并不意味着 b 的派生类部分被删除了,它们仍然存在于内存中,只是被隐藏了。如果将 q 再转换回 B 类型的指针 r,那么 r 又可以访问 b 的所有数据和函数了。这就是向下转型(downcasting)的过程。

此外,在向下转型时,使用 dynamic_cast 运算符可以检查是否安全进行转型。如果转型不安全,它将返回空指针,否则返回指向派生类对象的指针。在本例中,dynamic_cast 将 q 转换回 B* 类型,并且成功转型后,r 可以访问 B 类型新增的数据和函数。

2、强制转换

  1. 基本数据类型的转换

比如double(8字节)和int(4字节)之间的转换:
double强转为int会出现内存截断,但并不是单纯的去掉小数保留整数这么简单,而是double类型的这个数据的前四个字节中的内容,将其转换为int型,这个值是不可预料的。
同样,int强转为double会发生内存的扩张,这时候往转换后的内存中写数据会发生补课预料的行为。

double d5 =1000.25;
int *pInt =(int*)&d5;
int i4 = 100:
double *pDbl=(double*)&i4;

请添加图片描述

  1. double、float类型的存储形式?

float 和 double 都是用来存储浮点数的数据类型。它们的主要区别在于所占用的存储空间和精度。
float 类型通常占用4个字节(32位)的存储空间,而 double 类型通常占用8个字节(64位)的存储空间。

float 和 double 它们的存储形式分为三部分:符号位、指数位和尾数位。
符号位用来表示数值的正负
指数位用来表示数值的数量级
尾数位用来表示数值的精度
对于 float 类型,符号位占用1位,指数位占用8位,尾数位占用23位。
对于 double 类型,符号位占用1位,指数位占用11位,尾数位占用52位。

  1. C++中基类和派生类之间的指针强制转换

请添加图片描述

疑问:double转int为什么不是直接去掉小数部分
  1. double 转换为 int 为什么不是直接去掉小数部分

其实网上看的很多类型转换的文章,在 double 转 int 时,都是直接去掉小数就可以了,比如
csdn:如何在Java中将double转换为int?
牛客:将一个 double 类型数值显式类型转换为int 时,是
C#基础③——类型转换(int转double、double转int、Convert)

因此原文中说到

double强转为int会出现内存截断,但并不是单纯的去掉小数保留整数这么简单

我认为这里指的是去掉小数部分会涉及到精度问题,这是因为浮点数在计算机中的存储方法导致的。
浮点数是用二进制表示的,但并不是所有的十进制小数都能被二进制精确地表示。

例如,0.1在二进制中是一个无限循环小数,所以在计算机中只能用一个近似值来表示它。这就造成了一定的误差,当我们对浮点数进行运算或转换时,这些误差就会累积或放大,导致最终的结果与预期不符。比如在 double类型转int类型,精度丢失的问题 中,作者举了个例子:

double num1 =  49.99;
int num2 = (int)(num1 *100);

输出后,num2 等于 4998

因此,相对于书中原文来讲,我认为double转int还是可以直接去掉小数部分的(欢迎大家指正),如果为了确保精度,可以采取以下方法:

  • 使用Math类中的方法:Math类提供了一些方法来对浮点数进行取整操作,例如Math.round()可以将浮点数四舍五入到最接近的整数,Math.floor()可以将浮点数向下取整到最接近的整数,Math.ceil()可以将浮点数向上取整到最接近的整数。
  • 使用类型转换:将double转换为int时,可以加上一个偏移量,使得转换后的结果更接近原来的值。例如,int num2 = (int) (num1 * 100 + 0.5); 这样可以避免四舍五入的误差。

需要注意的是:

但这并意味着double类型是用四个字节存储小数,因为double的存储类型是浮点数,而int的存储类型是定点数表示的。
浮点数是一种可以表示非常大或非常小的数值的方式,它由三部分组成:符号位、指数位和尾数位。定点数是一种只能表示有限范围的整数或小数的方式,它由两部分组成:整数部分和小数部分。

因此原文中

而是double类型的这个数据的前四个字节中的内容,将其转换为int型,这个值是不可预料的

因为只保留前四个字节意味着会忽略掉double类型的数值的指数位和尾数位,导致转换后的结果不正确。
例如,double类型数值123.456的二进制表示为:

01000000 01011101 11001100 11001100 11001100 11001100 11001100 11001101

如果直接舍弃后面32位,取前32位,得到

01000000 01011101 11001100 11001100

转换为int类型的十进制就是1078530012,和原值相差其实挺大的。

参考:
C++将派生类赋值给基类(向上转型)
C++数据类型(强制)转换详解
double类型转int类型,精度丢失的问题


五、多维数组遍历的效率问题

就C++/C 多维数组来说,“先行后列”遍历效率肯定好于“先列后行”遍历,不管其行数远大于列数还是情况相反甚至接近,即使在最坏的情况下也至少与“先列后行”遍历的效率相当。影响效率的实际上主要是大型数组导致的内存页面交换次数以及 cache 命中率的高低,而不是循环次数本身。

这里的“先行后列”,“先列后行”指的是两层 for 循环,先遍历行还是先遍历列。

int a[10][3];

// “先行后列”
for(int i = 0; i < 10; ++i){
	for(int j = 0; i < 3; ++j){
		// ...
	}
}

// “先列后行”
for(int j = 0; i < 3; ++j){
	for(int i = 0; i < 10; ++i){
		// ...
	}
}

建议采用“先行后列”的方式,原因如下:

  1. 局部性原理:指的是如果一个数据或指令被访问过,那么它附近的数据或指令也很可能会被访问。这是因为程序的数据和指令通常是按照一定的顺序存储和执行的,比如数组、函数等。空间局部性可以利用预取等技术来提前加载数据或指令。
  2. 更少的缺页中断、页面调度和页面交换
    数组元素的访问是真正的随机访问(直接地址计算)。
    • 如果整个数组能够在一个内存页中容纳下,那么在对整个数组进行操作的过程中至少不会为了访问数组元素而出现缺页中断、页面调度和页面交换等情况,只需要一次外存读取操作就可以将数组所在的整个页面调入内存,然后直接访问内存就可以了。此时“先行后列”和“先列后行”的遍历效率差不多(仅有的差别是 cache 命中率不同,但是差别不大)。
    • 但是如果整个数组内容需要多个内存页来容纳的话,情况就大不一样了,尤其是列数较大的情况下。考虑一个计算情况,数组的每一行都占用一个内存页,那么每次遍历都会经历一次页面调度

二维数组遍历方式(先行后列、先列后行)差异测试
二位数组按行按列遍历效率问题【小细节】
理解局部性原理


总结:

本篇主要记录的是第四章的细节问题,比如 static 关键字,extern关键字的作用,函数重载的原理,和类型转换中没有关注到的一些问题,这些问题比较浅,还没有涉及到函数等内容,其中笔者本人对类型转换那一块接触的比较少,后续会单独开一篇讲讲这个部分。


该系列:
林锐《高质量程序设计指南C/C++》笔记02
林锐《高质量程序设计指南C/C++》笔记03

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值