分类 C/C++ 下的文章

在写编译原理的时候,在词法分析器类里放了一个ifstream类型的成员变量,然后给语法分析器写一个构造函数,把生成的词法分析器作为参数传入。

声明代码大致如下:

class SimpleLexiconAnalyzer
{
public:
    SimpleLexiconAnalyzer(string filename);
    Word scan();
private:
    ifstream stream;
    char nextChar();
    static ifstream openFile(string filename);
};

class GrammarAnalyzer
{
public:
    GrammarAnalyzer(SimpleLexiconAnalyzer tokenSrc);
    void parseRules(vector<Word> tokens);
    static bool equals(vector<Word> a, vector<Word> b);
private:
    SimpleLexiconAnalyzer &tokenSrc;
    map<Word, vector<WordSeq>> rules;
};

然后就碰到了标题所述的问题。发现问题出在

    GrammarAnalyzer(SimpleLexiconAnalyzer tokenSrc);

这一行。经过资料查阅后,发现在C++中stream类型的对象是不允许被复制的(在类的定义时,删除了operator=操作),决定换用引用。查了老半天,卡在了引用的初始化上(根据C++语法,引用变量是不允许重新赋值的),然后在stackoverflow上受到了一点启发,写出了如下代码:

GrammarAnalyzer::GrammarAnalyzer(SimpleLexiconAnalyzer &tokenSrc): tokenSrc(tokenSrc) {}

然后错误消失,编译顺利通过。

顺带一提,启发来自一年前写的数据结构代码。鬼知道为什么当时我能查到可以这么写,结果一年后不但全忘了,连查都快查不到了。。。

最近在学操作系统的时候,接触到了Linux的部分基础函数,比如与进程相关的forkexec等。其中exec是一个系列的函数,有一个名字相似的函数族,比如execvexecl等等。根据之前看到的某篇博客,叙述了exec后跟不同字母的含义:

l(可能代指list?)表示参数可以通过C/C++函数调用时的可变参数的形式调用exec,只需最后传递一个NULL表示参数结束即可; v(可能代指variable?)表示参数以一个字符串数组的形式将参数传递给待调用进程,同样要求最有一个元素为NULLp表示允许带PATH的相对路径搜索; e表示传递环境变量。

其中这个execvp,在MSVC中的thread.h中的定义是这样的:

_DCRTIMP intptr_t __cdecl execvp(
    _In_z_ char const*        _FileName,
    _In_z_ char const* const* _Arguments
    );

然后就看到了这么一个神奇的参数类型定义:char const* const*,在IntelliCode里给出的是另一个定义:const char* const*。这两者一样吗?分别代表的又是什么类型的数据?

然后就涉及到了最基本的问题,const修饰符与变量类型申明关键字,以及指针之间的关系。

两个在网上随便找找就能见到的例子:

char* const s1 = ...;
const char* s2 = ...;

两者分别定义了什么?有区别吗?

答案是,两者都定义了一个char*类型的指针,都具有某些值不能被修改的限制(const修饰符)。但是,第一个限制的是,s1本身不能被修改,而第二个限制的是不允许通过s2修改*s2的值。换句话说,可以对s1进行的相关写操作是修改*s1的内容,而可以对s2进行的相关写操作是修改s2本身。相应地,对s2不要求初始化,而s1一定要初始化,否则会报错,因为后续就不允许修改了,而使用没有初始化过的变量,结果又是不可预测的。

其中对声明的性质起到关键作用的是指针符号*的位置。

来自StackOverflow的一个答案:

int       *       mutable_pointer_to_mutable_int;
int const *       mutable_pointer_to_constant_int;
int       * const constant_pointer_to_mutable_int;
int const * const constant_pointer_to_constant_int;

从这几个声明可以看出,若是*const前面,则指针本身不可修改;否则指向的数据不可修改。可以结合指针的使用进行理解:定义的时候声明了const *p,则p可修改,*p不可修改;若是*const p,则*p可修改,p不可修改,此时const的作用域仅仅在p上。若是在*前后都加上const,则两者的作用复合,形成一个无论如何都无法修改的指针。

此外,由于const和数据类型的声明符的顺序没有影响,所以const int*int const*的作用是一样的。

然后就回到了char const* const*const char* const*的关系上。显然,由于char和第一个const在所有*的同侧,交换顺序不影响定义,所以两者是等价的。

那它定义了个啥呢?

首先去除所有的const,因为它仅仅是对于读写属性的限制,则实际的类型定义为:char **,也就是一个char类型的二级指针,即char*指针的指针。也就是说,定义的东西是一个传统的字符串数组,但是由于是使用指针而不是数组定义的,所以每个一维串的长度可以不一致,整个字符串数组的内存分配也不一定连续。然后再补回const属性,假设指针名为p,则该声明限定了*p**p是常量,不允许通过该指针进行修改。也就是说,char const* const* p定义了一个char类型的二级指针,且不允许修改该指针指向地址的值,这个值是一个直接指向char类型数据的指针;也不允许修改这个值作为指针,所指向的目标内存地址的值。所以重新把视角拉高,整个声明到底定义了啥?一个指向不允许内容被修改的字符串数组。从功能上看,也就像是Java编程时在某些地方声明的final对象一样,用于确保在执行该函数过程时,数据不会被有意或无意地修改。

好了,又到了快乐的C++快速上手时间 之前在这里讲过实际中.cpp.h的一种用法,然后这次涉及到了模板函数template。模板函数与传统函数不同,并无法直接使用,需要先传入所给定的参数类型,C++才能根据类型推断出该函数体实际所需要执行的指令。所以,应该将模板函数的函数体在头文件中实现。不然的话,比如说在Visual Studio中,若是还是傻傻地(像我一样)在头文件定义模板函数,然后跑到源文件中定义函数体的话,就会报出LNK2019,也就是常见的找不到函数引用的错误。

拓展阅读 -> https://stackoverflow.com/questions/495021/why-can-templates-only-be-implemented-in-the-header-file

在看操作系统的时候看到的,好骚啊。直接暴力使用结构体的地址进行推断,其间还巧妙地利用了编译器的优化原理。据说该方法广泛用于Linux内核。

使用宏定义,代码分三行,定义三层操作:

注意:由于在#define操作中使用空格符号作为定义与替换内容的分隔符,所以在定义时不要加入空格。替换内容中似乎可以包含空格,因为该宏指令没有第三个参数

第一行:对外提供功能的函数名

#define member2parent(member_ptr,member_name) \
          to_struct(member_ptr, struct Parent, member_name)

其中这里的member_ptr是需要用来寻找结构体首部地址的成员变量地址,member_name不是变量,而是该成员变量在结构体中定义的名称。这里巧妙地利用了宏定义的替换原理,能够传递非变量的元素。在这里将结构体的定义加进去,能够减少使用时的代码量(避免重复输入结构体名)。

第二行:实际上进行由成员地址计算出结构体首地址操作的部分

#define to_struct(member_ptr,type,member_name) \
          ((type*)(char*)(member_ptr) - offset_of(member_name, type))

这里做的事情不但将结构体的首地址计算出来,还将格式转换为了type类型的指针。又调用了一个新的宏offset_of

第三行:获取成员变量相对结构体首部的偏移

#define offset_of(type,member) ((size_t)(&(((type*)0)->member)))

https://blog.csdn.net/changqing1990/article/details/85256717 这里获取偏移的方法就很巧妙了。所使用的思路是,假设一个结构体的实体从0地址开始,那么只要取得成员变量的地址,就相当于取得了该成员变量相对于结构体首部的偏移。但是仔细观察的话,会发现一个问题:((type*)0)->member这么一个操作,所做的事情是取得一个指针对应的成员变量的值,对C语言稍有了解的话,会知道其实NULL和0是一个东西。对空指针取成员变量值,不会出错吗?实际上外面还有一层操作:取地址符&。如同上文链接所述,实际上很容易理解,对一个地址取值再取地址,可以直接简化为地址的变换操作。由于编译器发现这个值在过程中并未使用,于是就进行了这种优化,实际上0地址的数据并未被真实访问过,而我们也按照预期取得了偏移量的数值。

一个单文件的C语言源程序也是一个C项目。


在之前用C语言编程的时候,往往都是随意地新建一个.c.cpp文件,把代码往里面一写,gcc命令一敲,然后就在命令行里运行自己的程序,开始快乐苦B的debug之旅了。直到最近学习编译原理,了解了一些程序解析的原理之后,才开始把这个曾经思考过,却又未曾放在心上的问题拿出来解决。

在看了这篇文章之后,有了一定的新认识,决定也自己写点东西来加深一下理解。(看起来是位高中物竞,大学计科的dalao啊)

首先提出一个问题:C语言中的.c.h读者有见到过吗?在使用时,有注意到自己是在何种情况下使用两者的吗?

显然,一个接触过C语言的人,是一定见到过这两者的。因为,即使是一个编程界最基础的程序Hello World,在使用C语言编写时也必须同时用到这两种类型的文件。

回忆一下,步骤如下:

  1. 建立helloworld.c
  2. 使用任意文本编辑器(如记事本)打开,在内部输入如下内容:

    #include <stdio.h>
    int main()
    {
    printf("Hello world!\n");
    return 0;
    }
  3. Ctrl+S,关闭;
  4. 使用编译器编译(如GCC):gcc -o helloworld.exe helloworld.c
  5. 运行:helloworld.exe,或者./helloworld.exe

于是我们就见到了helloworld.cstdio.h两个文件。至少到这里,一般人都会有的想法:编写自己的程序用.c后缀,使用系统预定义的函数用.h后缀。为什么不能反过来呢?理论上可以。不过由于这已经形成了约定俗成的习惯,随意地违反规则,可能造成意料之外的结果。

接下来,按照大众称呼,将.c文件称呼为源文件source file.h文件称呼为头文件header file。何为源文件呢?程序是由代码编译生成的,代码是程序之源,因而代码又叫做程序的源代码;源代码中的文件自然而然地便可以称为源文件了。那何为头文件呢?浅显易懂的理解方法是,.h文件作为include预处理指令的操作对象,一般都是随预处理指令放在文件头部位置的。(C语言中代码功能与书写位置是有关的;作用域为定义位置开始向后,所以需要的功能最好在一开始就引用。

在上文所提到的文章中,作者表示,由于include预编译指令的实际作用是将目标文件直接复制到当前文件的同一位置,所以文件到底叫啥,有什么后缀名都无关紧要,因为文件名在include预编译指令中必须明确指定。一开始我还在这踩了个坑,以为头文件中函数的实现只能在名称对应的源文件中查找,直到我试了一下。。。发现实际上至少在Visual Studio中,只要是在解决方案资源管理器中添加了的文件,都算是源文件,即使名字不对应,后缀奇怪,也不会影响函数实现的搜索,平时常见的头文件和源文件命名对应的目的只是为了方便开发人员寻找函数的实现代码。换个说法,就是MSVC会在编译时把添加到项目资源管理器的文件都扫描一遍,生成对应的目标文件(符号表)。即使后缀改成exe,只要没从解决方案中排除,照样编译给你看

寻找的两种不同的库文件(Windows下的.dll,Linux下的.so;Windows下的.lib,Linux下的.a),依次对应的是动态编译和静态编译两种各有优劣的编译方法。在此不做展开,关于这个内容的博客在搜索引擎上还是很好找的。由于了解尚不够深入,目前还不清楚源文件的有无是否影响动态编译——反正静态是肯定不影响的:编译器往往提供了其C标准实现(有多种,如GCC的glibc,BSD的BSD libc,Windows平台下的MSVC Runtime等等)的静态编译库,而不提供其实现源码。(从某种程度上来讲实现了对源码的保护)

但是在最简单的Hello World例子中,我们是无法找到stdio.c这个文件的。因为往往编译器都提供的是静态库,如上一段所说,原因为何我也不清楚。。

由于Windows下的MinGW采用的C标准实现是MSVC的,因而在编译程序时,所需要的函数实现都是从MSVC对应的静态库里提取的。这个静态库一般是libmsvcrt.a,使用strings工具可以看出其中存在printf字符串,可以间接地表明其中含有printf的实现。顺便了解到Windows下与grep对应的命令是findstr

由include的原理所牵扯到的是,“所include的文件一定要是头文件,而不能是源文件吗?”这样一个问题。这个想法很好:把源文件复制到待引用文件的头部的话,相当于是只是把函数的定义换了个位置写,然后用编译器命令复制过去,在理论上是行得通的。然而若是亲自实践的话,会发现编译器报出符号多重定义的错误,即实际上并不可行。

这个思考方式忽视了一个问题:打开任何一个现有项目的的头文件查看,里面都是只有函数的原型声明的,没有任何实现代码。上面也有提到,编译器会自动到所有指定为源文件的文件中寻找函数的实现,来试图在链接过程使这个函数具体化。那么如何实现寻找呢?在对源码直接进行搜索的基础上进行改进,把对源文件的词法和语法分析的中间结果记录下来,也就是可能会听说过的.obj文件,里面就有编译器可以识别格式的文件信息,包括定义的变量、函数等。再把函数名字相同的函数寻找出来(由于C语言是面向过程型的语言,没有重载特性,故没有函数签名一说),作为函数声明的实现。也就是说,可以理解为,编译期先将源文件解析为一一对应的一系列“符号集合”,然后在“符号集合”的并集里寻找匹配的函数实现。那么,上面的问题就好解决了:如果include了一个源文件的话,声明引用的文件中会出现被引用文件的代码,这些函数被定义了一遍,放进该文件的符号表里;同时由于被引用的文件被视为了源文件,该文件自身也会有一个对应的符号表,把文件里的函数定义一遍。由于这个“并集”并不会、且出于严谨的目的也不能自动合并命名重复的函数实现,因而在链接阶段,链接器将不知道该采用哪个实现,直接报出多重定义的错误。

如果我不把被include的文件添加至项目呢?

这个更好回答,你告诉编译器这个不是源代码文件,编译器就会直接在词法分析预处理的阶段报错。需要的文件“不存在”(因为你告诉编译器不是这个文件,而它又找不到其他满足条件的文件),词法分析无法进行。

而函数的声明和定义就不一样了,声明是针对编译的过程而言的,为的是向语法分析器确认该函数的用法。至于该函数到底有啥用,语法分析器不管,因为它不需要关心自己所处理的句子的内在含义的问题,只要句子“读得通”(符合文法)就行。所以,函数的声明是不需要写入到符号表的,相同的声明可以在不同的源文件中同时出现。这个时候,头文件的作用就显现出来了:能够规范简洁而统一地对需要引用的同一(批)函数进行声明。这个功能还可以扩展至所有声明性的工作,包括宏定义#define、C++的类定义、结构体以及枚举类型的定义,等等。但凡涉及到实体化的工作,即使是变量声明,也不能放在头文件中,因为只要有实体的对象都会被添加至符号表,造成重定义的错误。

总结如下: .c文件里写代码(放实体),.h文件里放声明(说空话)。因为口说无凭,立字为据嘛