ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++标准库头文件正确用法

C++标准库头文件正确用法

前言


C++ 标准库由几十个头文件组成,每个头文件声明一组相关的设施。写代码时#include哪几个,看起来是小事,实际上直接影响可移植性、编译速度和代码的可维护性。


最常见的坏习惯是#include <bits/stdc++.h>。GCC 提供了这样一个扩展头,能一次引入几乎所有标准头,写竞赛代码很方便,但它是 GCC 的扩展,不是标准头文件;MSVC 不提供,Clang 搭配 libc++ 也不提供。把它带进正式项目,等于把代码绑死在某一个编译器上。


另一个普遍问题是「靠传递包含碰运气」:用了std::sort却不包含<algorithm>,因为<vector>在某套实现里恰好间接包含了它。标准不保证这种间接关系,换个标准库版本就可能编译失败。


还有一类混淆来自 C 头文件:<string.h>是 C 的字符串函数(strlen、memcpy),<string>是 C++ 的std::string,<cstring>是把前者放进std命名空间的版本。三者名字相近,用途完全不同。


本文讲标准头文件的分类、C 与 C++ 头文件的区别、include what you use原则、头文件保护写法,以及常见的包含顺序与循环依赖问题。


一、C 头文件与 C++ 头文件


C++ 标准库保留了 C 标准库的功能,提供两种形式:




C 形式C++ 形式内容



<stdio.h><cstdio>printf、fopen、FILE

<stdlib.h><cstdlib>malloc、free、atoi

<string.h><cstring>strlen、memcpy、strcmp

<math.h><cmath>sqrt、sin、floor

<ctype.h><cctype>isdigit、toupper

<time.h><ctime>time、clock

<stdint.h><cstdint>int32_t、uint64_t



区别在于命名空间:



  • <cstdio>保证名字在std命名空间中(如std::printf),是否同时放进全局命名空间由实现决定。

  • <stdio.h>保证名字在全局命名空间,是否同时放进std由实现决定。


新代码统一用<c...>形式,并写std::前缀。这样不会污染全局命名空间,也不会因为实现差异导致名字找不到。


注意<string>和<cstring>不是一回事:前者是std::string类,后者是 C 风格字符串函数。只写#include <string>却调用strlen,在多数实现上会编译失败(因为strlen声明在<cstring>里)。


二、include what you use 与头文件保护


原则很简单:代码里直接使用了哪个设施的哪个名字,就包含声明它的头文件,不依赖其他头文件的间接包含。


例如:


// 适用于 C++17
#include <algorithm> // std::sort
#include <iostream> // std::cout
#include <string> // std::string
#include <vector> // std::vector

不能因为<iostream>在某个实现里包含了<string>,就省掉<string>。标准只规定每个头文件必须提供哪些声明,不规定它不能额外包含什么,也不规定它必须包含什么。不同标准库实现(libstdc++、libc++、MSVC STL)的包含关系差别很大,依赖它就是把可移植性交给运气。


这条原则还有一个好处:减少不必要的编译依赖。一个源文件只包含真正需要的头,改动无关头文件时不会触发大面积重编译。


2.1 前向声明


如果只需要指针或引用,不需要完整类型,可以用前向声明代替包含:


// 适用于 C++17
class Widget; // 前向声明

void process(const Widget& w); // 参数是引用,不需要完整定义

但以下情况必须包含完整定义:



  • 按值传递或返回对象。

  • 继承自该类。

  • 访问成员。

  • 在类中定义该类型的成员对象。

  • 使用sizeof、static_cast到该类型(部分转换除外)。


标准库为此提供了<iosfwd>,其中前向声明了std::ostream、std::istream等类型,适合只在头文件里声明operator<<的场景:


// 适用于 C++17
#include <iosfwd>

std::ostream& operator<<(std::ostream& os, const Widget& w);

实现文件里再包含<ostream>。


2.2 头文件保护


自定义头文件必须防止重复包含,否则同一个翻译单元里可能出现重复定义。两种写法:


// 适用于 C++17
#ifndef MY_WIDGET_H
#define MY_WIDGET_H

class Widget { /* ... */ };

#endif // MY_WIDGET_H

或者:


// 适用于 C++17
#pragma once

class Widget { /* ... */ };

#pragma once更简洁,几乎所有主流编译器都支持,但它不是标准的一部分,行为细节(比如符号链接、大小写不敏感文件系统)由实现决定。包含保护(include guard)是标准做法,宏名要唯一,通常用「项目名_文件名_H」的形式。


无论用哪种,都要保证头文件可以独立包含:单独写一个只包含该头文件的源文件,不应当出现「找不到声明」的错误。这要求头文件自己包含它需要的所有依赖,而不是要求调用方先包含某些东西。


三、头文件里不该放什么


3.1 不要放定义


在头文件里定义普通函数或全局变量,被多个源文件包含时会产生重复定义(multiple definition)。正确做法是:



  • 函数声明放头文件,定义放源文件。

  • 需要头文件内定义时,用inline(函数)或inline变量(C++17)。

  • 模板和constexpr函数可以放头文件,它们有特殊的链接规则。


// 适用于 C++17
// header.h
inline int square(int x) { return x * x; } // 可以放头文件
inline constexpr int kMaxSize = 1024; // C++17 内联变量

3.2 不要写 using namespace


// 适用于 C++17
// 错误:头文件里的 using 指令会污染所有包含者
using namespace std;

头文件被谁包含是不可控的,一条using namespace std;可能让别人的count、distance、data全部产生二义性。即使在源文件里,也建议限制在函数作用域内。


3.3 不要在头文件里写匿名命名空间


匿名命名空间的成员在每个翻译单元中各有一份。放在头文件里,每个包含者都得到独立副本,容易出现「改了一个,另一个没变」的问题,也会增大二进制体积。头文件里的常量用inline constexpr。


四、包含顺序与头文件速查


推荐的头文件包含顺序(从特殊到一般):



  1. 本文件对应的头文件(如果是.cpp)。

  2. C 系统头文件。

  3. C++ 标准库头文件。

  4. 第三方库头文件。

  5. 本项目其他头文件。


把「自己的头文件」放第一位,可以验证它是否能独立包含:如果它漏了某个依赖,会立刻暴露,而不是被后面包含的头文件掩盖。


循环依赖(A 包含 B,B 又包含 A)通常意味着设计有问题。常见的破解方式:



  • 用前向声明替代包含。

  • 把共同依赖抽到第三个头文件。

  • 用 pimpl 隐藏实现细节。


包含保护只能防止无限递归,不能解决「A 和 B 互相需要完整定义」的语义问题。


4.1 常用头文件速查




设施头文件



std::vector、std::array<vector>、<array>

std::string<string>

std::string_view(C++17)<string_view>

std::sort、std::find<algorithm>

std::cout、std::cin<iostream>

std::ostringstream<sstream>

std::unique_ptr、std::shared_ptr<memory>

std::optional、std::variant(C++17)<optional>、<variant>

std::function<functional>

std::thread、std::mutex<thread>、<mutex>

std::atomic<atomic>

std::size_t、std::ptrdiff_t<cstddef>

std::int32_t<cstdint>

std::filesystem(C++17)<filesystem>

std::memcpy、std::strlen<cstring>

std::sqrt、std::floor<cmath>

std::numeric_limits<limits>

std::runtime_error<stdexcept>



完整示例


// 适用于 C++17
// widget.h
#ifndef MY_WIDGET_H
#define MY_WIDGET_H

#include <iosfwd>
#include <string>

class Widget {
public:
explicit Widget(std::string name);
const std::string& name() const;
private:
std::string name_;
};

std::ostream& operator<<(std::ostream& os, const Widget& w);

#endif // MY_WIDGET_H

// 适用于 C++17
// widget.cpp
#include "widget.h"

#include <ostream>
#include <utility>

Widget::Widget(std::string name) : name_(std::move(name)) {}

const std::string& Widget::name() const { return name_; }

std::ostream& operator<<(std::ostream& os, const Widget& w) {
return os << w.name();
}

头文件里std::ostream只以引用形式出现,所以用<iosfwd>前向声明即可;源文件里真正要写输出,才包含<ostream>。std::string是成员类型,必须包含<string>。这就是「按需包含」的具体应用。


常见坑点



  1. ❌ 用<bits/stdc++.h>:


``cpp #include <bits/stdc++.h> // GCC 扩展,不可移植`` ✅ 显式包含用到的标准头。



  1. ❌ 依赖传递包含:


``cpp #include <vector> std::sort(v.begin(), v.end()); // 没有包含 <algorithm>`✅ 直接包含<algorithm>`。



  1. ❌ 把<string.h>当成std::string:


``cpp #include <string.h> std::string s; // 编译错误`✅ C++ 字符串用<string>,C 字符串函数用<cstring>`。



  1. ❌ 头文件里写using namespace std;。


✅ 写std::前缀,或只做using std::string;且放在源文件。



  1. ❌ 头文件缺少包含保护,被重复包含导致重定义。


✅ 用 include guard 或#pragma once,并保证头文件可独立包含。



  1. ❌ 在头文件里定义非 inline 函数或全局变量。


✅ 声明放头文件、定义放源文件;需要头文件内定义时用inline。



  1. ❌ 用<iostream>只为声明std::ostream参数。


✅ 用<iosfwd>前向声明,实现文件再包含<ostream>。



  1. ❌ 头文件里放匿名命名空间变量。


✅ 用inline constexpr定义常量,避免每个翻译单元一份副本。


总结




原则做法



用 C++ 形式<cstdio>而不是<stdio.h>

按需包含用什么包含什么,不靠传递

可独立包含头文件自己带上依赖

防重复include guard 或#pragma once

不污染头文件里不写using namespace

减少依赖只需声明时用前向声明 /<iosfwd>

定义归位非 inline 定义放源文件



标准库头文件的用法,核心只有一句「include what you use」。它同时解决了三个问题:可移植性(不依赖某套实现的包含关系)、编译速度(不引入无关依赖)、可读性(一眼看出这个文件用了什么)。把<bits/stdc++.h>留在竞赛里,把明确的包含带进项目,是成本最低的工程习惯之一。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进