c++ 编程小技巧

160 阅读8分钟

在 C++ 开发过程中,掌握一些实用的编程技巧能够显著提升代码质量和开发效率。本文总结了几个在实际项目中非常有用的 C++ 编程模式和最佳实践。

前向声明: 减少头文件依赖

将核心类的声明集中在一个头文件中,减少编译依赖:

// forward.h
#pragma once

class Document;
class Element;

在其他文件中只需要包含前向声明头文件:

// my_class.h
#include "forward.h"
#include <memory>

class MyClass {
    std::unique_ptr<Document> document;  // 只需要指针,前向声明足够
    Element* element;        
};

优势:

  • 减少编译时间: 只包含类声明而非完整定义,避免引入大量依赖文件
  • 解决循环依赖问题: 统一的声明中心让相互依赖的类不再需要直接包含对方的头文件

静态工厂方法: 处理可能失败的对象创建

构造函数无法返回错误信息,但可以使用静态工厂方法来处理可能失败的初始化:

#include <memory>
#include <optional>
#include <fstream>

class FileProcessor {
public:
    // 静态工厂方法,可以返回失败信息
    static std::optional<FileProcessor> create(const std::string& filename) {
        std::ifstream file(filename);
        if (!file.is_open()) {
            return std::nullopt;  // 创建失败
        }
        
        // 私有构造函数,确保初始化成功
        return FileProcessor(std::move(file));
    }
    
    void process() {}

private:
    explicit FileProcessor(std::ifstream file) : file_(std::move(file)) {}
    
    std::ifstream file_;
};

// 使用方式
auto processor = FileProcessor::create("data.txt");
if (processor) {
    processor->process();
} else {
    // 处理创建失败的情况
    std::cerr << "Failed to create processor\n";
}

优势:

  • 明确的错误处理
  • 避免部分初始化的对象
  • 构造成功即可用

作用域守卫: 确保某些逻辑离开作用域后执行

#include <functional>

// 简单的作用域守卫实现
class ScopeGuard {
public:
    explicit ScopeGuard(std::function<void()> cleanup) 
        : cleanup_(std::move(cleanup)), active_(true) {}
    
    ~ScopeGuard() {
        if (active_) cleanup_();
    }
    
    void dismiss() { active_ = false; }  // 取消守卫
    
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;

private:
    std::function<void()> cleanup_;
    bool active_;
};

void complex_operation() {
    FILE* file = fopen("temp.txt", "w");
    ScopeGuard file_guard([file] { if (file) fclose(file); });
    
    // 复杂操作...
    if (some_condition1()) {
        return;  // 自动关闭文件
    }

    if (some_condition2()) {
        rollback_guard.dismiss();  // 取消回滚
        return;
    }
    
    // 正常结束也会自动关闭文件
}

优势:

  • 异常安全
  • 自动资源管理
  • 代码简洁

constexpr: 编译时计算

使用 constexpr 将计算从运行时移到编译时:

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int pow2(int exp) {
    return 1 << exp;
}

void example() {
    constexpr int size = factorial(5);     // 编译时计算: 120
    constexpr int buffer_size = pow2(10);  // 编译时计算: 1024
    
    int array[size];           // 可用于数组大小
    char buffer[buffer_size];  // 性能零开销
}

优势:

  • 零运行时开销:计算在编译时完成
  • 可用于常量表达式:数组大小、模板参数等
  • 更好的优化:编译器能做更多优化

模板特化: 处理特殊情况

为特定类型提供优化实现:

// 通用模板
template<typename T>
void print(const T& value) {
    // 默认实现
    std::cout << value;
}

// 特化:bool 类型特殊处理
template<>
void print<bool>(const bool& value) {
    std::cout << (value ? "true" : "false");
}

// 特化:指针类型
template<typename T>
void print(T* ptr) {
    if (ptr) {
        print(*ptr);
    } else {
        std::cout << "null";
    }
}

void example() {
    print(42);        // 输出: 42
    print(true);      // 输出: true (不是 1)
    int x = 10;
    print(&x);        // 输出: 10
    print<int*>(nullptr); // 输出: null
}

优势:

  • 类型特定优化:为不同类型提供最佳实现
  • 保持统一接口:调用方式一致
  • 编译时选择:无运行时开销

span: 安全的数组视图

std::span (C++20) 提供了一个轻量级的数组视图,无需拷贝数据就能安全地访问连续内存:

#include <span>
#include <array>
#include <vector>

void process_data(std::span<const int> data) {
    // 统一处理各种容器类型
    for (int value : data) {
        // 处理每个元素...
    }
    
    // 编译时验证 span 特性
    static_assert(sizeof(std::span<int>) == sizeof(void*) + sizeof(size_t));
}

void example() {
    // 原生数组
    int arr[] = {1, 2, 3, 4, 5};
    process_data(arr);
    
    // std::array
    std::array<int, 3> std_arr = {10, 20, 30};
    process_data(std_arr);
    
    // std::vector
    std::vector<int> vec = {100, 200, 300, 400};
    process_data(vec);
    
    // 子范围
    process_data(std::span(vec).subspan(1, 2));  // {200, 300}
}

优势:

  • 零开销抽象:只存储指针和大小,无需拷贝数据
  • 类型统一:一个函数可处理数组、vector、array等所有连续容器
  • 子范围操作:轻松创建原数据的子视图

variant 的优雅访问方式

std::variant 为我们提供了类型安全的联合体,但如何优雅地访问其中的值往往令人困扰。以下介绍三种逐步进化的访问方式:

使用 std::visit

最基本的方式是使用 std::visit 搭配访问者模式:

#include <iostream>
#include <variant>

// 定义示例类型
struct Apple {};
struct Orange {};
using Fruit = std::variant<Apple, Orange>;

// 方法一:经典访问者类
struct FruitVisitor {
    void operator()(const Apple&) const {
        std::cout << "发现一个苹果" << std::endl;
    }
    void operator()(const Orange&) const {
        std::cout << "发现一个橘子" << std::endl;
    }
};

void printFruit1(const Fruit& fruit) {
    std::visit(FruitVisitor{}, fruit);
}

// 方法二:使用 lambda 表达式(更现代的方式)
void printFruit2(const Fruit& fruit) {
    std::visit([](const auto& f) {
        using T = std::decay_t<decltype(f)>;
        if constexpr (std::is_same_v<T, Apple>) {
            std::cout << "发现一个苹果" << std::endl;
        } else if constexpr (std::is_same_v<T, Orange>) {
            std::cout << "发现一个橘子" << std::endl;
        }
    }, fruit);
}

Match 语法糖

为了让代码更简洁,我们可以实现一个 Match 辅助类:

template <class... Ts>
struct Match : Ts... {
    using Ts::operator()...;
};
// C++17 推导指引
template<class... Ts> Match(Ts...) -> Match<Ts...>;

void printFruit3(const Fruit& fruit) {
    std::visit(Match{
        [](const Apple&) { std::cout << "发现一个苹果" << std::endl; },
        [](const Orange&) { std::cout << "发现一个橘子" << std::endl; },
    }, fruit);
}

运算符重载实现模式匹配

通过重载管道运算符,我们可以实现更优雅的模式匹配语法:

template <typename... Ts, typename... Fs>
constexpr decltype(auto) operator|(const std::variant<Ts...>& v, const Match<Fs...>& match) {
    return std::visit(match, v);
}

void printFruit4(const Fruit& fruit) {
    fruit | Match{
        [](const Apple&) { std::cout << "发现一个苹果" << std::endl; },
        [](const Orange&) { std::cout << "发现一个橘子" << std::endl; },
    };
}

优势:

  • 类型安全:编译时确保处理所有可能的类型
  • 语法简洁:管道运算符提供函数式编程风格
  • 性能优化:编译器可以内联优化访问代码

内部链接: 匿名命名空间 vs static

在 C++ 中,我们经常需要限制变量、函数或类的可见性,使其只在当前文件内可见。实现这一目标有两种主要方式:匿名命名空间(anonymous namespace)或使用 static 关键字。现代 C++ 更推荐使用 匿名空间。

匿名命名空间

namespace {
    int x = 42;
    void foo() { /* ... */ }
    class Bar { /* ... */ };

    template<typename T>
    class Calculator { };
}

特点:

  • 匿名命名空间中的实体具有内部链接属性(internal linkage)
  • 编译器会自动为匿名命名空间生成一个唯一的名称
  • 可以包含任何类型的声明(变量、函数、类、模版等)

static

static int x = 42;
static void foo() { /* ... */ }
// static class Bar { /* ... */ };  // 不能用于类声明

特点:

  • 使变量或函数具有内部链接属性,不能用于类声明和模版
  • 编译器会自动为变量和函数生成唯一的名称

C++ 词法作用域查找规则

C++ 中的作用域按照从内到外的查找顺序:

  1. 局部作用域(Local Scope):函数内的代码块 {}
  2. 函数作用域(Function Scope):函数参数和标签
  3. 类作用域(Class Scope):类的成员变量和成员函数
  4. 命名空间作用域(Namespace Scope):命名空间内(包括匿名命名空间)
  5. 全局作用域(Global Scope):文件级别的全局声明

C++ 标识符查找遵循在某层作用域找到名称匹配就停止查找,然后在该层的所有候选中进行重载解析的规则。这意味着:

  • 不是找遍所有作用域然后选择最佳匹配
  • 而是在某一层找到同名标识符后立即停止向外查找
  • 然后只在该层的候选函数中进行重载解析

匿名命名空间查找陷阱实例

// 函数查找
namespace Demo {
    void fun(int /*unused*/) {}        // 候选1:精确匹配 int
    
    static void fun(float /*unused*/) {
        fun(1);  // ✓ 在命名空间 A 中找到 fun(int) 和 fun(float)
                 //   重载解析选择 fun(int)(精确匹配)
    }
    
    namespace {
        void fun(double /*unused*/) {  // 候选2:需要转换 int→double
            // fun(1);     // ✗ 在匿名命名空间中找到 fun(double),查找停止!
                           //   只有一个候选,选择 fun(double),递归调用自己
            
            Demo::fun(1);     // ✓ 明确指定从命名空间 A 开始查找
        }
    }
}

// 变量查找
namespace Demo {
    int value = 1;
    
    namespace Inner {
        double value = 2.0;  // 隐藏外层的 int value
        
        void test() {
            // auto x = value;     // 类型是 double,不是 int!
            auto x = Demo::value;  // 明确指定获取 int value
            static_assert(std::is_same_v<decltype(x), int>);
        }
    }
}

使用 位域 压缩空间

位域(bit fields)允许将结构体成员按位存储,有效压缩内存空间。

enum class E : std::uint8_t {
    A,
    B,
    C,
};

struct S {
    E e;
    bool b;
};
static_assert(sizeof(S) == 2);

struct S_B {
    E e   : 2;
    bool b: 1;
};
static_assert(sizeof(S_B) == 1);

但是 位域 会对性能造成影响:

struct NormalFlags {
    bool flag1, flag2, flag3, flag4;  // 4字节,快速访问
};

struct BitFlags {
    bool flag1 : 1, flag2 : 1, flag3 : 1, flag4 : 1;  // 1字节,需要位操作
};

// 性能对比
void performance_test() {
    NormalFlags normal;
    BitFlags bit;
    
    // 普通成员:直接内存访问
    normal.flag1 = true;   // 单条指令
    
    // 位域:需要位操作
    bit.flag1 = true;      // 需要读取-修改-写入操作
}