多文件编程:c/c++分文件写法(入门)

目录

  • **前言**
  • **简单案例**
  • **为什么这么做呢?**
  • **1. 避免头文件被重复引用**
  • **2. 命名空间 和 类的声明与定义分离**
  • **3. 尽量不要在头文件中使用 using namespace xxx;**
  • **4. 在头文件中定义常量**
  • **最后**

前言

一个 C++ 项目通常会采取 声明与定义分离 的方式进行编写,其基本遵循:头文件中写声明,源文件中写定义
此外,为了区分头文件与源文件,会采用不同的文件后缀:

.h: 头文件
.cpp: 源文件
(当然还有其他的)

这么做有什么好处?

  • 方便管理与维护项目
    如果你的项目只用一个文件:将所有的代码都写到一个源文件中。倘若代码量上千甚至更多,那么当你需要滑动屏幕查找某个函数时,这就很费劲。
    相反,如果你将其拆分为多个文件,将具有同一逻辑功能的代码放入同一文件中,并用文件名加以标识,那么当你需要修改某个逻辑功能对应的代码时,只需要找到对应文件即可。

  • 避免不必要的重复编译
    一个项目只有一个 main.cpp 文件,所有的代码都在其中,并且已经项目编译好了。当你需要改动部分代码时,哪怕只是一行,整个文件都要重新进行编译。显然我们更期望编译器只编译改动部分的代码。

    因为 c++ 编译器是以 文件 为单位进行编译,将源文件编译为 .obj 文件,然后在进行链接。

    所以可以将 main.cpp 合理的拆分为多个文件,当我们改动某部分代码,假设这些代码只在一个文件中,那么编译器只需要重新编译此文件,而不会将所有文件都重新编译一遍,减少了我们的等待时间。

  • 可以用来隐藏内部实现
    比如我写了一个函数 print(),我不想别人知道我是怎么实现这个函数的,只告诉别人它的功能,那么就可以提供其编译好的 静态库(或动态库)以及对应的 头文件 给用户,这样用户就只能使用,而不会知道内部是怎么实现的。

  • … …

那么怎么写呢?先来看一个简单案例:


简单案例

首先需要知道一个简单规则:当使用某函数时,此函数必须已经被定义

  • 在 main 中使用 print 函数:

    #include <iostream>
    
    void print()		// 定义
    {
        std::cout << "hello" << std::endl;
    }
    
    int main()	
    {
        print();
        return 0;
    }
    

上面的代码也可以用下面的代码替换:

  • 通过前置声明

    #include <iostream>
    
    void print();		// 前置声明
    
    int main()	
    {
        print();
        return 0;
    }
    
    void print()		// 定义
    {
        std::cout << "hello" << std::endl;
    }
    

因此你可以用前置声明的方式去理解分文件写法:

  • 将 print 的前置声明放到 print.h 文件中
  • 将 print 的定义放到 print.cpp 文件中 (需要导入 print.h)
  • 在需要使用 print 函数的文件 (main.cpp) 导入 print.h 即可使用

为什么这么做呢?

  • 预处理器
    C++编译器在编译源文件时,第一步一般都是预处理。预处理其中一个步骤是将 #include
    指定的文件内容展开。
    比如有文件如下:
// print.h
void print();
// main.cpp
#include "print.h"

int main()
{
    print();
    return 0;
}

那么在预处理阶段,上面的 main.cpp 将转为如下

// main.cpp
void print();   // #include "print.h" 被展开

int main()
{    
    print();
    return 0;
}

因此 main.cpp 导入 print.h 的一个目的是为了让编译器知道 print 函数的存在(前置声明),那么编译 main.cpp 时就不会报错 未知符号 print
但这还存在一个问题:print 函数没有定义。

  • 链接阶段
    C++编译器最后一个阶段:将所有源文件编译生成的目标文件进行 链接 生成最终的可执行文件。
    如果只有上面两个文件:print.h,main.cpp,那么会报错 引用 print 未定义
    在上面的例子中,main.cpp 编译产生目标文件 main.obj,在此过程中 print 被加入它的符号表,此时编译器只知道 print 这个符号是存在的(被声明过),但是并不知道它的定义在什么地方。因此加入如下文件:
#include "print.h"

void print()		// 定义
{
    std::cout << "hello" << std::endl;
}

现在再编译 main.cpp、print.cpp,由于 print.cpp 导入了 print.h,所以编译器知道 main.cpp 与 print.cpp 同属于一个项目,那么会将 main.obj、print.obj 进行链接。由于 print.obj 中有 print 的定义,所以编译通过,程序能成功运行。

从这个过程中你也能看出一个规则:一个符号可以多次声明,但是只能定义一次
对于声明语句,编译器只是知道这个符号是存在的,所以声明多次是可以的,但是只能定义一次,倘若同一个符号定义了多次,那编译器该用哪一个?


【扩展】#include <xxx> 与 #include “xxx”

  1. 作用:两种方式都是用来导入头文件,可以理解为:#include <file.h> (或者 #include “file.h”)被预处理器更换为 file.h 中的内容
  2. 差异:
    (1)前者被称为 标准包含 或者 系统包含,编译器会在标准库的头文件目录查找指定的文件,这些目录通常是编译器预定义的,比如 GCC 在 linux 上会查找 /usr/include、/usr/local/include 等目录。
    (2)后者称为 局部包含 或者 用户定义包含,编译器首先会搜索当前文件目录查找指定的文件,如果没有找到,编译器会继续在标准库的头文件目录查找。

因此你将 #include <iostream> 换为 #include “iostream” 仍然能正常运行,但是不建议这么做,因为后者的效率显然没有前者高,同时没有标识性。


【例】项目结构如下:

在这里插入图片描述

  • print.h 内容
    在这里插入图片描述

  • print.cpp 内容
    在这里插入图片描述

  • main.cpp 内容
    在这里插入图片描述

编译并运行此项目:
在这里插入图片描述

总结一下分文件的基本写法:将原本的单文件划分为不同模块,将不同模块放到不同文件中。在头文件中写函数声明,对应的源文件中先导入头文件,再写函数定义;在需要使用到此函数的文件中,导入头文件即可。

下面来看几个注意事项:


1. 避免头文件被重复引用

当一个项目有很多头文件时,难免会造成有的头文件被多次引用(导入),为了

  • 防止编译错误
    如果一个头文件中包含有一个宏的定义、变量的定义或者函数的定义等,那么当它被多次引用时,很可能造成重复定义的错误。
  • 一定程度提高编译效率
    虽然现代编译器通常会优化掉一些有重复引用引起的无效代码,但是重复引用仍然可能会增加编译器负担,降低编译效率。
  • … …

我们可以采用如下方面来避免:

  • 宏定义

    #ifndef _NAME_H
    #define _NAME_H
    
    // 头文件内容
    
    #endif
    

    需要注意:宏 _NAME_H 必须是独一无二的,一般为头文件的文件名
    简单说说它是怎么实现避免重复引用的:

    #ifndef _NAME_H
    当第一次引用次头文件时,
    宏 '_NAME_H' 没有被定义,
    那么就会执行它下面的代码。
    
    #define _NAME_H 
    现在定义了 _NAME_H
    
    // 头文件内容
    
    #endif 	
    
    当之后再引用此头文件时,
    _NAME_H 已经定义了,
    因此跳过 
    '#ifndef _NAME_H''#endif'
    之间的内容
    
  • #pragma once
    #pragma once 写在头文件的第一行,那么编译器会保证此文件只会被导入一次。它的效率比 宏定义 要高,但是需要注意在一些老版本编译器上不支持此指令。


2. 命名空间 和 类的声明与定义分离

Student.h:

#pragma once

namespace std 
{
class Student 
{

public:
	void play();
private:
	string name;
};

}

那么需要 注意命名空间与类名
Student.cpp:

#include "Student.h"

void std::Student::play()
{
	// ...
}

// 或者也可以换为
namespace std 
{
	
void Student::play() 
{
		// ...
}

}


3. 尽量不要在头文件中使用 using namespace xxx;

如果你对 c++ 的命名空间不太了解的,可见作者的另外一篇文章 c++ namespace

在 C++ 中,标准库中的所有函数、类等都被定义在 std 这一命名空间中,如果你在头文件中使用了 using namespace std;,那么导入此头文件的所有源文件对于 std 中所有定义都是直接可见的,可能会造成命名冲突。


4. 在头文件中定义常量

有时我们需要在头文件中定义 常量,以供其他源文件使用,但是为了避免编译错误,你可以如下定义:

  • 可用 constexpr 修饰的常量直接定义在头文件中
    constexpr 是 c++11 引入的新特性,可以用来修饰基本类型(比如 int、char 等),但是复杂类型无法修饰(比如 string 等)
  • 使用 extern 修饰 const
    // file.h	
    extern const string ss;		// 声明
    
    // file.cpp 
    const string ss = "ss";		// 定义
    

最后

在写头文件与源文件的过程中你会发现一个重复性的工作:需要一个函数需要被写两次,并进行一些增删,比如:

// 头文件
namespace std 
{

void fic(const string& s = "");

} 

// 源文件
namespace std 
{
	
void fic(const string& s)  // 删除默认参数
{		
	// ...
}	

}

代码少时没多大感觉,但较多时就比较麻烦,这里推荐作者自己写的一个命令行小工具(我称为 header_to_file),能够 读取头文件中的声明语句,自动生成定义语句,并输出到源文件中避免重复性工作,有兴趣的前往 header_to_file 了解。

  • 34
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值