StoneのBLOG

生活这种事情,从来都是自我陶醉

0%

C++编程思想手抄

好久没有看书了,最近重拾看书的习惯,放着一大本的全局光照技术不看,想看看C++的内容。因为最近在调查一个工具,看那个人写的源码,深入看下去发现这个人写的代码是真的很好,跟我之前所在现场的时候看见的那个架构十分相似,但是一个是C#,一个是C++。

想要把那份源码记在脑子里似的,希望能够多过几遍,之后肯定会用的上的。

所以言归正传,聊聊现在看的书,C++编程思想。我会记下来我觉得非常有意义的话语,多看多读,能够印在脑子里面是最好,因为有些东西知道了就想会在代码中潜移默化的表现出来。

第一章 对象导言

在面向对象的程序设计中,答案是非常新奇的:编译器不做传统意义上的函数调用。由非OOP编译器产生的函数调用会导致与被调用代码的早捆绑(early binding),对于这一术语,读者可能还没有听说过,因为从来没有想到过它。早捆绑的意思是,编译器会对特定的函数名产生调用,而连接器将这个调用解析为要执行代码的绝对地址。在OOP中直到程序运行时,编译器才能确定执行代码的地。所以,当消息被发送给一般对象时,需要采用其他的方案。

为了解决这一问题,面向对象语言采用晚捆绑(late binding) 的思想。当给对象发送消息时,在程序运行的时候才去确定调用的代码。编译器保证这个被调用的函数存在,并执行参数和返回类型的检查【其中不采用这种处理方式的语言称为弱类型(weakly typed) 语言】,但是它并不知道将执行的确切代码。

为了执行晚捆绑,C++编译器在真正调用的地方插入一段特殊的代码。通过使用存放在对象自身中的信息,这段代码在运行时计算被调用函数函数体的地址(这一过程将在15章中详细介绍)。这样每个对象就能根据这段二进制的内容有不同的行为。当一个对象接收到消息时,它根据这个消息判断应该做什么。

我们可以用关键字virtual声明他希望某个函数有晚捆绑的灵活性。我们并不需要懂得virtual的使用机制,但是没有它,我们就不能用C++进行面向对象的程序设计。在C++中,必须记住添加virtual关键字,因为根据规定,默认情况下成员函数不能动态捆绑。virtual函数(虚函数)可用来表示出现在相同家族中的类具有不同的行为。这些不同是产生多态行为的原因。

上面的话对我从新理解C++的面向对象的程序的理解是有帮助的。

比如说这段话对于virtual的理解,不是说遇到这种情况的话需要使用virtual关键字,而是清楚地描述了这种情况下,virtual关键字可以帮助我们来实现。以前只是知道这个关键字是用来声明虚函数,但是为什么要声明虚函数呢,什么情况下声明虚函数却不清楚。

第二章 对象的创建与使用

这一章虽然都是大白话,但是对于底层的描述说到底还是不是完全理解的,所以需要细致的去理解。

2.1 语言的翻译过程

计算机语言转化为机器指令需要翻译器

通常,翻译器分为两类:解释器(interpreter)编译器(compiler)

为什么要录下这段话是因为我以为翻译器只有编译器一种呢。Python使用的就是解释器。而C++使用的就是编译器。虽然解释器与编译器之间的界限也很模糊(听说的)。

但是C++的重点在与编译器的理解上。我也有一个独立写一个编译器的梦想……

2.1.3 编译过程

关于编译的过程我看的是云里雾,所以详细了调查了一些文章。

  • Building C Projects - Alex Smith关于编译过程的详细说明
    • 1.Configuration(配置)
      • 用户系统环境配置的详细参数信息。以便编译器适应不同的用户环境配置。
    • 2.Standard dircetor detection(确定标准库位置)
    • 3.Source file dependency calculation(确定依赖关系)
    • 4.Header file location(确定头文件位置)
    • 5.Header precompilation(头文件的预编译)
    • 6.Preprocessing(预处理)
    • 7.Compilation and assembly(编译)
    • 8.Object file dependency calculation
    • 9.Linking(连接)
      • 编译器把外部函数的代码添加到可执行文件中。静态连接动态连接
    • 10.Installing(安装)
    • 11.Resource linking
    • 12.Package generation(生成安装包)
    • 13.Dynamic linking(动态连接)

时间关系就直接把编译过程的大概列出来吧。

某些语言(特别是C/C++)编译时,首先要对源代码进行预处理,预处理器(preprocesser) 是一个简单的程序,它用程序员(利用预处理器指令)定义好的模式代替源代码中的模式。预处理指令用来节省输入,增加代码的可读性。(C++程序设计并不鼓励多使用预处理指令,因为他可能引起一些不易发现的错误,这些将在本书的后面分析。)预处理过的代码通常放在一个中间文件中。

编译一般分两遍进行。首先,对预处理过的代码进行语法分析。编译器把源代码分解成小的单元,并把它们按树形结构组织起来。表达式“A+B”中的“A”,“+”和“B”就是语法分析树的叶子节点。

有时会在编译的第一遍和第二遍之间使用全局优化器(global optimizer) 来生成更短,更快的代码。

编译的第二遍,由代码生成器(code generator) 遍历语法分析树,把树的每个节点转化成汇编语言或机器代码。如果代码生成器生成的是汇编语言,那么还必须用汇编器对其汇编。两种情况的最终结果都是生成目标模块(通常是一个以.o.obj为扩展名的文件)。有时也会在第二遍中使用窥孔优化器(peephole optimizer) 从相邻一段代码中查找冗余语句。

上述内容大致描述了编译过程,应该还涉及到了许多之前尚未完全理解的内容吧。

2.2 分段编译工具

程序可由多个文件构成,一个文件中的函数可能要访问另一个文件中的函数和数据。编译一个文件时,C或C++编译器需要知道在另一个文件中的函数和数据,特别是它的名字和基本用法,编译器就是要确保函数和数据被正确的使用。”告知编译器“外部函数和数据的名称及它们的模样,这一过程就是声明(declaration) 。一旦声明了一个函数或变量,编译器知道怎样检查对它们的引用,以确保引用正确。

这一段话告知了声明这一概念,为什么需要声明,声明用来做什么的。声明就是像编译器告知外部自己的存在以及如何使用自己。

2.2.1 声明与定义

之前从未对这两个概念进行细致的区分,或者说根本没有去注意。理解这两个概念会发现,这两个概念,还蛮重要的…

声明(declaration) 是向编译器介绍名字-标识符。它告诉编译器“这个函数或这个变量在某处可以找到,它的模样像什么”。而定义(definition) 是说:“在这里建立变量”或“在这里建立函数”。它为名字分配存储空间。无论定义的函数还是变量,编译器都要为它们在定义点分配存储空间。对于变量,编译器确定变量的大小。,然后在内存中开辟空间来保存量的数据。对于函数,编译器会产生代码,这些代码最终也要占用一些内存。

上面的内容说明了声明跟定义的区别。声明就好比开店之前的宣传,编译器拿着传单知道了这家店的信息,使用情报,而定义就是真正的开店开业,是在上述传单的描述中真实存在的一家店。

在C/C++中,可以在不同的地方声明相同的变量和函数,但是只能有一个定义【有时这称为ODR(one-defifition rule,单一定义规则)】。当连接器连接所有的目标模块时,如果发现一个函数或变量有多个定义,连接器将报告出错。

相同的变量或函数可以多次声明,但是定义只能有一次。

定义也可以是声明。如果定义int x;之前,编译器没有发现标识X,编译器则把这一标识符看成是声明并立即为它分配存储空间。

2.2.1.4变量声明的语法

关于变量的声明,由于文章的说明有些多,直接写下自己的理解:

1
int a;

这只是一个非常常见的变量声明,这是声明?还是定义?

这段代码有足够的信息让编译器为整数a分配空间,而且编译器也确实给整数a分配了空间。要解决这个矛盾,对于C/C++需要一个关键字来说明“这只是一个声明,它的定义在别的地方”。这个关键字就是extern,它表示变量是在文件以外定义的,或在文件后面部分才定义。

1
extern int a;  //声明一个变量但是不定义它

结果就是,对于变量来说简单的声明所提供的情报足以让编译器为其定义。想要停止这种编译器自动的行为就需要使用extern关键字来告诉编译器说我要晚一点再定义这个变量,你先知道有这么个变量就行了。

对于函数来说又是什么样子的呢?

1
int func1(int length, int width);

1
extern int func1(int length, int width);

这两种声明方式有区别吗?

因为没有函数体,编译器必定把它作为声明而不是函数定义。extern关键字对函数来说是多余的,可选的。C语言的程序设计者并不要求函数声明使用extern,这可能有些令人遗憾;

无论加还是不加,编译器都认为这种定义方式都没有足够的信息去定义一个函数,因此都会被视为声明。通过理解声明定义的区别,应该可以灵活运用函数与变量的出现位置。

关于extern关键字使用的拓展,目的是加深理解这个关键字的作用。参看文章

extern除了告诉编译器这只是一个声明之外,还有一个作用是跟"C"一起连用的时候,是告诉编译器按照C的规则来办事。比如说下面的例子:

1
extern "C" void fun(int a, int b);  //出于上述的文章中的描述

按照C的规则来翻译这个声明的函数,貌似按照C++的翻译规则,编译器会将函数名变得跟fun不一样,要看编译器的”脾气”。这个跟C++的函数重载特性有关。
下面的内容全是选自上面的文章:

  • extern变量

    • 在一个源文件里定义了一个数组:char a[6];在另一个文件里声明extern char *a; 这种声明可以吗?

      • 答案是不可以。程序运行会告诉你非法访问。原因是类型不同,指向类型T的指针并不等价于类型T的数组,不难发现,这是指针与数组使用中经常出现的盲区知识,若是对于指针的理解只有半吊子的水平还喜欢炫耀的话就会在此栽跟头。正确的声明应该是:

        1
        extern char a[];

        在使用extern的时候应该严格对应声明的格式。

    • extern常常被用作全局变量来使用,利用其这种特性,在.h文件中使用extern来声明。
  • extern “C”

    • C++语言在编译的时候为了解决函数的多态问题,会将函数名和参数联合起来生成一个中间的函数名称,而C语言则不会,因此会造成链接时找不到对应函数的情况,此时C函数就需要用`extern “C进行链接指定,这告诉编译器,请保持我的名称,不要给我生成用于链接的中间函数名。
    • 这应该是在C++环境中使用C函数的时候应该注意的问题。
  • extern函数声明

    • 原文中举了一个例子,总的来说就是extern对于函数来说就像上文提到的可加可不加,没有明显的区别,仅仅就是一个暗示,可能这个函数会在别的源文件里面定义。
    • 当把全局变量的声明跟定义放在一起的时候,会因为#include的存在而产生重复定义的链接错误。所以:只在头文件中做声明,真理就是这么简单。当然不使用#include语句,将想要提供给外部接口的函数和变量全部使用extern来修饰也是一种方法。你用么,反正我不用。
  • extern和static

    • (1)extern表明该变量在别的地方已经定义过了,这里要使用那个变量。
    • (2)static表示静态的变量,分配内存的时候,存储在静态区,不存储在栈上面。
    • stati作用范围是内部连接的关系,跟extern一样,修饰的部分是跟对象分开存储的,但是却不能被其他对象引用,而extern可以。static修饰的变量只允许对象本身使用。具体差别首先:static跟extern是一对”水火不容”的家伙,也就是说,extern和static不能同时修饰一个变量;其次,static修饰的全局变量声明与定义同时进行,也就是说你在头文件中使用static声明了全局变量之后,它同时被定义了;最后,static修饰的全局变量的作用域只能是本事的编译单元,也就是说它的全局只对本编译单元有效,其他编译单元则看不到它。如:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      // test1.h
      static char g_str[] = "123456";
      void fun();

      // test1.cpp
      #include "test1.h"
      void func1(){ cout<<g_str<<endl; }

      // test2.cpp
      #include "test1.h"
      void func2(){ cout<<g_str<<endl; }

      以上的两个编译单元可以连接成功,你可以在各自的.obj文件中找到字符串"123456"的存在。虽然它们有相同的内容,但是存储的物理地址并不一样,就像是两个不同的变量赋了相同的值一样,而这两个变量分别作用于它们各自的编译单元。

    • 一般定义static全局变量的时候,都把它放在原文件中而不是头文件,这样就不会给其他模块造成不必要的信息污染。
  • extern和const

    • C++中的const修饰的全局常量有跟static相同的特性,即它只能作用于本编译模块中,但是const可以和extern连用来声明该常量可以作用于其他编译模块中,如extern const char g_str[];,然后在原文件中别忘了定义:const char g_str[] = "123456";
    • 所以当单独使用的时候它就与static相同,而当与extern一起合作的时候,它的特性就跟extern一样了。最后是该作者的提醒:
      1
      2
      3
      4
      5
      6
      const char* g_str = "123456";    // const修饰的是char*而不是g_str
      // 与下面的写法
      const char g_str[] = "123456";
      //
      const char* const g_str = "123456";
      `

上面算是对extern关联的一些拓展内容吧。

2.2.1.5包含头文件

#include预处理指令有两种方式来指定文件:尖括号(< >)或双引号。

#include <header>用尖括号来指定文件时,预处理器是以特定的方式来寻找文件,一般是环境中或编译器命令行指定的某种寻找路径。这种设置寻找路径的机制随机器,操作系统,C++实现的不同而不同,视具体的情况而定。

#include "local.h"用双引号时,预处理器以”由实现定义的方式“来寻找文件。它通常是从当前目录开始寻找,如果文件没有找到,那么include命令就按与尖括号同样的方式重新开始寻找。

包含iostream头文件要用如下语句

#include <iostream>

包含头文件的两种方式的区别。

2.2.2 连接

连接器把由编译器生成的目标模块(一般是带.o.obj扩展名的文件)连接成为操作系统可以加载和执行的程序。它是编译过程的最后阶段。

2.2.3 使用库文件

2.2.3.1连接器如何查找库

当C或者C++要对函数或变量进行外部引用时,根据引用的情况会选择两种处理方式。

一是如果未遇到过这个函数或者这个变量的定义,就把它的标识符加到未解析的引用列表中,如果连接器遇到过他们的定义,就是已解决的引用。

二是如果连接器没有在目标模块中找到它们的定义,就去查找库。

库有某种索引方式,连接器不会去浏览库中的所有目标模块,而是浏览索引。如果找到了就把函数或变量定义所在的目标模块连接到可执行程序。

这里需要注意的是连接的是目标模块而不是整个库,因此在构造自己的库的时候,一个源码文件只有一个函数,可以减少程序包的大小。

2.2.3.2秘密的附加模块

当创建一个C/C++的可执行程序的时候,连接器会秘密连接某些模块。其中之一是启动模块,它包含了对程序的初始化例程。初始化例程是开始执行C/C++程序时必须首先执行一段程序。初始化例程建立堆栈,并初始化程序中的某变量

连接器总是从标准库中查找程序中调用的经过编译的标准函数。由于标准库总可以被找到,所以只要在程序中包含所需的头文件,就可以使用库中的任何模块,并且不必告诉连接器去找标准库。

如果使用附加的库,必须把该库文件名添加到由连接器处理的文件列表中。

上面的内容给我揭示了一个盲区,貌似我之前的水平都没有接触到,标准库以外的内容。如果有一天我发现标准库的内容满足不了,我需要别的库的实现,除了在代码中引用之外,我还应该修改连接器维护的一个文件列表,把库的名字加进去。至于应该怎么做,不太清楚了。