跳转至

[C++ Basic] Grammar

概要

C++ 基础知识和语法,包括C++11,C++17,C++23的各种语言支持。

C++编程语言历史 和 设计思路

在纷繁多变的世界里茁壮成长:C++ 2006–2020

C 与 C++、java 的 区别

支持范式模板编程 (generic programming)

  1. 模板代码(增加泛型编程能力,类似python),
  2. 泛型编程是一种以通用性为中心的编程范式。在泛型编程中,程序通过使用参数化类型(或称为模板)来实现数据类型无关的算法和数据结构
  3. 强⼤的 Standard Template Library (STL) 标准库, 也是基于泛型编程的产物。
  4. 包括:容器、迭代器、算法和函数对象 四类
  5. 元编程(e.g., constexpr )编译时推导出变量是否为固定常数。
  6. 一些语法和关键字,增加了 new 和 delete,auto

支持面向对象编程 (object-oriented programming) 的拓展

  • 类和对象:C++允许定义类和创建对象。类是一种用户自定义的数据类型,可以包含成员变量和成员函数。对象是类的一个实例,可以通过类来创建多个对象。C语言中没有类和对象的概念,只能使用结构体和函数来组织数据和行为。
  • 封装:C++支持封装,可以将数据和相关的操作封装在一个类中,并使用访问修饰符来控制对类成员的访问权限。C语言没有封装的概念,所有的数据和函数都是公开的。
  • 继承:C++支持继承,允许创建派生类从基类继承属性和行为。继承可以实现代码重用和类的层次化。C语言没有继承的概念。
  • 多态:C++支持多态,允许通过基类指针或引用来调用派生类的虚函数,实现动态绑定和运行时多态性。C语言没有多态的概念。
  • 异常处理:C++提供异常处理机制,可以通过抛出和捕获异常来处理程序中的错误和异常情况。C语言没有内置的异常处理机制。
C++ 与 java 的区别
  1. 内存管理:C++中的内存管理是手动的,程序员需要显式地分配和释放内存。C++提供了new和delete关键字来进行动态内存分配和释放。Java中的内存管理是自动的,使用垃圾回收机制来自动管理内存,程序员不需要手动释放内存。
  2. 指针:C++支持指针操作,允许直接访问和修改内存地址。Java中没有指针的概念,所有的数据访问都是通过引用进行的。
  3. 运行环境:C++是一种编译型语言,源代码在编译后被转换为机器码,并直接在操作系统上运行。Java是一种解释型语言,源代码在编译后生成字节码,然后由Java虚拟机(JVM)解释执行。
  4. 平台依赖性:C++代码在不同的平台上需要重新编译,因为它直接与底层系统交互。Java代码是平台无关的,一次编译的字节码可以在任何支持Java虚拟机的平台上运行。

  5. C++更适合系统级编程、游戏开发等需要更高的性能和底层控制的场景。

  6. Java更适合企业级应用开发、网络编程等需要跨平台和可移植性的场景。

基础知识与坑

程序执行入口

The default program entry function is main, but can be changed in two situations:

  1. use stupid #define xxx main in header file to replace the name which maybe ignored by silly search bar in VSCODE.
  2. use -exxx compile flag

语句末尾的分号

  • 在 C++ 中,是否需要在语句的末尾使用分号(;)取决于上下文。
  • C++ 语法的基本规则是:分号用来标识一条语句的结束,而有些结构并不是严格的语句,因此不需要分号。

  • 声明(变量、类、结构体、枚举、函数原型、类型别名):都需要分号作为结束。

  • 函数定义控制语句(如 if, while, for)、复合语句{}) 不需要分号。
  • 预处理指令(如 #define, #include):不需要分号,因为它们不是 C++ 语法层面的内容。
  • 作用域结束的 } 不需要分号,但声明类或结构体时 } 后要加分号。

为什么类/结构体需要分号

C++ 中的类和结构体定义实际上是一种声明,它们的定义是一种复杂的声明语句,因此必须用分号来结束它们。

总结来说,分号用来结束语句,包括声明、表达式和执行体等,但当你定义一个复合结构(如函数定义、控制语句)时,不需要分号来结束复合结构的定义。

逗号运算符(Comma)

重名变量的优先级

int getKthAncestor(int node, int k) {
    int node= getKthAncestor(saved[node],--k);  
    return node;
}
 //为什么第二行的node会提前被修改为0,导致传入函数getKthAncestor的saved[node]的node值为0
 //如下去掉int,也不会错。因为int node 会初始化node为0
 int getKthAncestor(int node, int k) {
    node= getKthAncestor(saved[node],--k);  
    return node;
}

根据C++的作用域规则,内层的局部变量会覆盖外层的同名变量。因此,在第二行的语句中,node引用的是函数参数中的node,而不是你想要的之前定义的node。

为了避免这个问题,你可以修改代码,避免重复定义变量名。例如,可以将第二行的变量名改为newNode或其他不同的名称,以避免与函数参数名冲突。

运算符优先级

运算符性质:

  • 接受的操作数,
  • 优先级,
    • 特殊:逻辑和(&&)先于逻辑或(||)、四则运算先于位运算
    • 位运算优先级低于判断符号,记得写括号。
    • 赋值(=)优先级最低
  • 结合性,
    • 左结合性: 大部分运算(加减乘除)
    • 右结合性:赋值运算符。程序会先计算它右边的表达式的值,然后再计算它左边的表达式的值
  • 返回值
    • 赋值运算符的返回值是赋值后左操作数的引用

变量类型以及Macro constants

https://en.cppreference.com/w/cpp/language/types

https://en.cppreference.com/w/cpp/types/integer

//返回与平台相关的数值类型的极值
std::numeric_limits<double>::max()
std::numeric_limits<int>::min()

#include<limits.h>
INT_MAX
FLT_MAX (or DBL_MAX ) 
-FLT_MAX (or -DBL_MAX ) 

关键词

extern 
const
constexpr //C++11引入的关键字,用于编译时的常量与常量函数。
volatile    //是指每次需要引用某个变量的数据时,都必须从内存原地址读取,而不是编译器优化后寄存器间接读取.(必须写回内存,为了多进程并发而设计的。)
inline 

static 关键字

static 作⽤:控制变量的存储⽅式和作用范围(可⻅性)。

  1. 修饰局部变量
    • 存放位置:栈区 -> 静态数据区(data段或者bss段)
    • 生命周期:程序结束才会释放
    • 作用域:还是局部代码块
  2. 修饰函数与全局变量
    • 使其作用范围由全工程文件可见变成了本文件可见
多文件共用static变量,需要添加 extern 关键字, 去掉static关键字

在 C++ 中,如果你希望 static 变量可以在多个文件中访问,直接写在头文件中是 不正确的,因为每个包含该头文件的源文件都会生成自己的独立 static 变量,导致它们互相独立,无法共享状态。

正确的解决方案

  1. 如果需要共享(全局变量的方式) 你应该将 encounteredAclops 声明为 extern 变量,并将其定义在一个 .cpp 文件中。

头文件:globals.h

#ifndef GLOBALS_H
#define GLOBALS_H

extern bool encounteredAclops;

#endif // GLOBALS_H

源文件:globals.cpp

#include "globals.h"

bool encounteredAclops = false;

在其他源文件中使用:

#include "globals.h"

void someFunction() {
    if (!encounteredAclops) {
        // Do something
        encounteredAclops = true;
    }
}

通过 extern,所有引用 encounteredAclops 的源文件都会共享同一个变量。


  1. 如果每个文件需要独立的变量

如果每个源文件都需要独立的 encounteredAclops,你可以将 static bool encounteredAclops = false; 放在各自的源文件中,而不需要放在头文件中。这是因为 static 的作用域仅限于当前编译单元。

每个源文件:

static bool encounteredAclops = false;

void someFunction() {
    if (!encounteredAclops) {
        // Do something
        encounteredAclops = true;
    }
}


  1. 如果需要在类中管理(推荐做法) 可以考虑将 encounteredAclops 作为一个类的静态成员变量来实现共享状态。

头文件:AclopsManager.h

#ifndef ACLOPS_MANAGER_H
#define ACLOPS_MANAGER_H

class AclopsManager {
public:
    static bool encounteredAclops;
};

#endif // ACLOPS_MANAGER_H

源文件:AclopsManager.cpp

#include "AclopsManager.h"

bool AclopsManager::encounteredAclops = false;

在其他源文件中使用:

#include "AclopsManager.h"

void someFunction() {
    if (!AclopsManager::encounteredAclops) {
        // Do something
        AclopsManager::encounteredAclops = true;
    }
}

这种方式既可以共享变量,又能保持代码组织清晰。


结论 - 如果需要共享变量:使用 extern 或类的静态成员变量。 - 如果需要独立变量:将 static 声明放在各自的源文件中。 - 不要直接在头文件中定义 static bool encounteredAclops,否则会导致每个包含头文件的源文件都生成自己的副本,违背初衷。

  1. 修饰类内函数
    • 静态成员函数:使用"static"修饰的成员函数称为静态成员函数。静态成员函数与类的对象无关,可以在没有创建对象的情况下直接通过类名调用。这意味着它们不需要通过类的对象来访问,而是属于整个类的。举例
    • 静态成员函数没有隐式的this指针,因此不能直接访问非静态成员变量和非静态成员函数。静态成员函数可以访问类的静态成员变量和其他静态成员函数。
    • static 成员函数不能被 virtual 修饰, static 成员不属于任何对象或实例,所以加上 virtual没有任何实际意义;
      • 静态成员函数没有 this 指针,虚函数的实现是为每⼀个对象分配⼀个vptr 指针,⽽ vptr 是通过 this 指针调⽤的,所以不能为 virtual;虚函数的调⽤关系,this->vptr->ctable->virtual function。
  2. 修饰类内的变量
    • 存放位置:栈区 -> 静态数据区(data段或者bss段)
    • 生命周期:程序结束才会释放
    • 意味着下一次调用函数时,静态局部变量将保持上一次调用时的值。
    • 由于不再属于某个类对象,可以直接通过类名初始化 int MyClass::staticVariable = 10;

多线程场景,修饰局部变量,会导致多线程共用,建议使用thread_local来避免竞争

static 局部变量在第一次被访问时初始化,且初始化过程是线程不安全的。如果两个线程几乎同时首次访问这个变量,可能会导致初始化竞争,进而引发未定义行为。

static 修饰初始化命令,只会执行一次,无论是否多次经过

问题场景:

static auto thread_core_map = GetCpuAffinityMap(device_id); 即使程序多次调用函数经过这行,但是这行命令也只会执行第一次。但是如果我把static关键词去除,就正常执行多次了。

当在一行代码中使用了 static 关键字时,变量的初始化只会在它第一次被执行时进行,之后即使多次经过这行代码,初始化的代码块也不会被重复执行

在例子中,thread_core_map 是一个静态局部变量,它在第一次经过时会调用 GetCpuAffinityMap(device_id) 函数并保存结果。在后续的函数调用中,即使再次经过这行代码,GetCpuAffinityMap(device_id) 不会被重新调用,因为 thread_core_map 已经被初始化过了。

去除 static 后,thread_core_map 会在每次经过这行代码时重新初始化,也就是每次都会调用 GetCpuAffinityMap(device_id)

解决方法:

如果你需要这行代码每次执行时都重新调用 GetCpuAffinityMap(device_id),那么应该去掉 static 关键字,或根据不同条件进行显式地重新初始化静态变量。

比如,可以这样实现惰性初始化或重置的功能:

static auto thread_core_map = GetCpuAffinityMap(device_id);
// 根据条件重新初始化
if (/* 条件 */) {
    thread_core_map = GetCpuAffinityMap(device_id);
}

这样你就可以在特定条件下让 thread_core_map 被重新赋值。

如果你对静态变量的初始化行为没有问题,但是希望特定场景下重新执行初始化函数,可以根据场景调整条件逻辑。

静态成员函数
#include <iostream>

class MyClass {
public:
    static void staticFunction() {
        std::cout << "This is a static member function." << std::endl;
    }
};

int main() {
    MyClass::staticFunction(); // 直接通过类名调用静态成员函数
    return 0;
}

const 关键字

当const修饰基本数据类型时,可以将其放置在类型说明符的前面或后面,效果是一样的。const关键字用于声明一个常量,即其值在声明后不可修改。

const int constantValue1 = 10; // const在类型说明符前
int const constantValue2 = 20; // const在类型说明符后

当const关键字位于指针变量或引用变量的左侧时,它用于修饰指针所指向的变量,即指针指向的内容为常量。当const关键字位于指针变量或引用变量的右侧时,它用于修饰指针或引用本身,即指针或引用本身是常量。

  1. 修饰指针指向的变量, 它指向的值不能修改:

    int x = 5;
    const int* ptr = &x;  // 指向常量整数的指针
    // *ptr = 10;        // 错误:不能通过const指针修改值
    x = 10;               // 合法:可以修改变量本身的值
    
  2. 修饰指针本身 ,它不能再指向别的变量,但指向(变量)的值可以修改。:

    const int y = 10;
    int* const ptr = &y;  // 常量指针指向整数
    // ptr = &x;         // 错误:不能修改指针本身
    // *ptr = 5;         // 合法:可以修改常量变量的值
    
  3. const int *const p3; //指向整形常量 的 常量指针 。它既不能再指向别的常量,指向的值也不能修改。

explicit

在C++, explicit 是一个关键字,用于修饰单参数构造函数,用于禁止隐式类型转换。

当一个构造函数被声明为 explicit 时,它指示编译器在使用该构造函数进行类型转换时只能使用显式调用,而不允许隐式的类型转换发生。

通过使用 explicit 关键字,可以防止一些意外的类型转换,提高代码的清晰性和安全性。它通常用于防止不必要的类型转换,特别是在单参数构造函数可能引起歧义或产生意外结果的情况下。

preprocessor directive

  • #include_next作用是 在寻找头文件时的头文件搜索优先级里,去除该文件所在的当前目录,主要是为C++头文件的重名问题提供一种解决方案。
    • 正确的用法:代码b.cpp想使用 自己拓展修改的stdlib.h, 那么在代码的目录下创建stdlib.h,并在该文件里#include_next "stdlib.h" 防止递归引用。

define、 const、 typedef、 inline

  • define:
    • define是一个预处理器指令,用于创建宏定义。它在编译之前对源代码进行简单的文本替换。可以用来定义常量、函数宏和条件编译等。
    • 优势:灵活性上占优,特别是在需要获取文件名、行号控制编译(不同平台编译)时行为的场景
    • 缺点:宏的调试和排错难度相对较高,因为宏的展开发生在编译前,出错时通常不容易直接定位到宏展开的具体代码。相比函数,宏的类型检查不严格,容易导致隐含的错误
    • 例如:#define PI 3.14159,在代码中将PI替换为3.14159。
# 是 字符串化操作符(Stringizing operator)

在 C/C++ 宏中,#字符串化操作符(Stringizing operator),它的作用是将宏参数转换为字符串文字(string literal)。

当在宏中使用 #key 时,key 被转换为一个字符串文字,即在代码中实际变为 "key"(包括引号)。如果不加 #key 将直接作为标记被使用,不会转为字符串。


示例:

以下是一个宏的例子,演示了 # 的作用:

宏定义:

#define TO_STRING(x) #x
#define CONCAT_AND_PRINT(a, b) printf("Concatenation: %s\n", TO_STRING(a##b));

宏使用:

int main() {
    int HelloWorld = 42;

    // 使用字符串化操作
    printf("%s\n", TO_STRING(HelloWorld)); // 输出: HelloWorld

    // 使用标记粘贴操作和字符串化操作
    CONCAT_AND_PRINT(Hello, World);        // 输出: Concatenation: HelloWorld
}

宏展开与作用:

  1. TO_STRING(HelloWorld)
    TO_STRING 将参数 HelloWorld 转换为字符串文字,展开为:

    printf("%s\n", "HelloWorld");
    
    输出:HelloWorld

  2. CONCAT_AND_PRINT(Hello, World)

  3. a##bHelloWorld 拼接成 HelloWorld
  4. TO_STRING(a##b) 将拼接后的标识符 HelloWorld 转换为字符串 "HelloWorld"。 展开为:
    printf("Concatenation: %s\n", "HelloWorld");
    
    输出:Concatenation: HelloWorld
## 是 C/C++ 宏预处理器的 标记粘贴操作符(Token-pasting operator)

操作符将 valueName 粘贴到 aaa 和 bbb 之间,形成新的标识符。例如,使用 aaa##valueName##bbb 这样的语法是完全有效的。

用于将宏参数和宏内的其他标记连接起来。具体来说,##Value##Initialized 这两个标记会与宏参数(如 valueName)进行拼接,形成新的标识符。

#define REGISTER_OPTION_CACHE(type, valueName, ...)                 \
    static thread_local type valueName##Value;                      \
    static thread_local bool valueName##Initialized = false;        \
    inline type GetWithCache##valueName() {                         \
        if (!valueName##Initialized) {                              \
            valueName##Value = __VA_ARGS__();                       \
            valueName##Initialized = true;                          \
        }                                                           \
        return valueName##Value;                                    \
    }                                                               \
    inline void SetWithCache##valueName(type value) {               \
        valueName##Value = value;                                   \
        valueName##Initialized = true;                              \
    }

宏展开解释

假设你在代码中使用了如下调用:

REGISTER_OPTION_CACHE(int, MyValue, 42);

这将会将宏中的 type 替换为 intvalueName 替换为 MyValue,并且 __VA_ARGS__ 代表了传递给宏的可变参数 42

展开后的代码:

static thread_local int MyValueValue;
static thread_local bool MyValueInitialized = false;

inline int GetWithCacheMyValue() {
    if (!MyValueInitialized) {
        MyValueValue = 42;  // 使用了 __VA_ARGS__
        MyValueInitialized = true;
    }
    return MyValueValue;
}

inline void SetWithCacheMyValue(int value) {
    MyValueValue = value;
    MyValueInitialized = true;
}

关键点解释:

  • valueName##Value 被展开为 MyValueValue## 操作符将宏参数 MyValueValue 连接,形成新的标识符 MyValueValue
  • valueName##Initialized 被展开为 MyValueInitialized,同样是将宏参数 MyValueInitialized 连接,形成新的标识符 MyValueInitialized

结果

  • MyValueValue 存储了缓存的值(在这个例子中是 42)。
  • MyValueInitialized 是一个布尔值,用来标记缓存是否已经初始化。
  • GetWithCacheMyValue() 函数首先检查 MyValueInitialized 是否为 true,如果没有被初始化,它会使用 42 来初始化缓存并设置标志。
  • SetWithCacheMyValue(int value) 函数允许你更新缓存的值,并将 MyValueInitialized 设置为 true
  • const:
    • const用于声明一个常量,指示标识符的值在程序执行期间不能被修改。
    • const可以用于变量、函数参数、函数返回类型和成员函数。使用const可以提高代码的可读性和安全性。
    • 例如:const int MAX_VALUE = 100;,声明一个名为MAX_VALUE的常量。
  • typedef:
    • typedef用于为数据类型创建别名。它可以用于为复杂的数据类型提供更简洁的名称,增强代码的可读性和可维护性。
    • typedef创建的别名可以像原始类型一样使用,并且不会引入新的类型,只是为已有类型提供了一个新的名称。
    • 例如:typedef int Age;,为int类型创建了一个别名Age。
  • inline:
  • inline用于声明内联函数,它是一种编译器的建议,用于将函数的定义直接插入到调用处,以避免函数调用的开销。
  • 内联函数通常在函数体较小且频繁调用的情况下使用,可以提高程序的执行效率。
  • inline关键字只是给编译器一个提示,编译器可以选择忽略该提示。在大多数情况下,编译器会自动进行内联优化。
  • 例如:inline int add(int a, int b) { return a + b; },声明了一个内联函数add。

  • define主要用于宏定义,const用于声明常量,typedef用于创建类型别名,inline用于内联函数的声明。

#ifndef & #pragma once

为了避免同一个文件被include多次,C/C++中有两种方式,一种是#ifndef方式,一种是#pragma once方式。在能够支持这两种方式的编译器上,二者并没有太大的区别,但是两者仍然还是有一些细微的区别

new & delete

  • new和delete 相对于 malloc/free 分配和释放堆空间。
  • 额外会执行构造函数和析构函数
#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "Constructing MyClass" << std::endl;
    }

    ~MyClass() {
        std::cout << "Destructing MyClass" << std::endl;
    }
};

int main() {
    // 使用new动态分配内存,并调用构造函数
    MyClass* obj = new MyClass();

    // 执行一些操作...

    // 使用delete释放内存,并调用析构函数
    delete obj;

    return 0;
}

namespace

namespace 会影响 typedef 的作用范围,但不会直接限制 #define 宏的作用范围。

头文件

  • 相互引用,前置声明
  • include头文件其实就是将对应的头文件内容贴在include的位置

A.h, B.h 都需要string.h的头文件,然后B.h 会include A.h,那么我在B.h里是不是可以省略include string.h

不应该省略,

  1. 防止代码变更引发的问题: 如果某天 A.h 中移除了 #include ,而 B.h 依赖 A.h 提供的 #include ,那么 B.h 将会因找不到 std::string 而编译失败。因此,显式包含依赖的头文件可以避免这种隐含依赖引发的问题。
  2. 提高可读性和自包含性: 每个头文件应该尽量做到自包含,意思是每个头文件应该独立地包含所有它所需要的头文件。这样做的好处是,任何其他文件都可以安全地单独包含 B.h,而无需额外关心它依赖于哪些头文件。
  3. 减少隐式依赖: 隐式依赖(依赖另一个头文件帮你包含所需的头文件)可能导致维护性问题。显式 #include 可以让代码更具可预测性和可维护性。

include的位置有什么规则和规律吗,头文件和cpp文件前都可以吗?

在编写代码时,往往A.cpp需要include A.h。那A.cpp需要的头文件,我是写在A.cpp里还是A.h里?

  1. 头文件 (A.h):只包含声明所需的头文件,不包含仅在实现中需要的头文件。
  2. 源文件 (A.cpp):包含所有实现需要的头文件,特别是那些仅在实现部分用到的头文件。此外,A.cpp 应该总是包含 A.h。6.

函数的特殊写法

函数传参

  1. 值传递
  2. 引用传递
  3. 指针传递
//值传递
change1(n);
void change1(int n){
    n++;
}

//引用传递,操作地址就是实参地址 ,只是相当于实参的一个别名,在符号表里对应是同一个地址。对它的操作就是对实参的操作
    change2(n);
    void change2(int &n){
        n++;
    }
    //特殊对vector
    void change2(vector<int> &n)
    //特殊对数组
    void change2(int (&n)[1000])

//指针传递,其实是地址的值传递
change3(&n);
void change3(int *n){
    *n=*n+1;
}

引用传递和指针传递的区别:

  • 引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)。
  • 不能有NULL引用,引用必须与合法的存储单元关联(指针则可以是NULL)。
  • 一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)。

指针传递和引用传递的使用情景:

  1. 函数内部修改参数并且希望改动影响调用者。
  2. 当一个函数实际需要返回多个值,而只能显式返回一个值时,可以将另外需要返回的变量以指针/引用传递

闭包、匿名函数

闭包是捕获并持有了外部作用域变量的函数。

闭包(Closure)是指在程序中,函数可以捕捉并记住其作用域(环境)中的变量,即使在函数执行完成后,这些变量依然保存在内存中,并能在后续的函数调用中被使用。闭包的一个重要特性是,它不仅保存了函数本身的逻辑,还“闭合”了函数执行时的上下文环境(即该函数所在的作用域)。

闭包通常用于实现函数内部的状态保持、回调函数等场景。在 C++ 中,闭包通过 lambda 表达式 实现,lambda 表达式可以捕获外部变量并在其内部使用。

例子

auto add = [](int x) {
    return [x](int y) {
        return x + y;
    };
};

auto add5 = add(5);
std::cout << add5(3); // 输出 8
在上面的例子中,add 函数返回了一个闭包,捕获了变量 x 的值。即使 x 在原作用域中不再可用,返回的闭包仍然可以访问并使用 x 的值。

  • 匿名函数是一种没有被绑定标识符的函数
  • lambda 是一种匿名函数
  • lambda 可以表示闭包

匿名函数(lambda)和闭包的关系就如同类和类对象的关系

匿名函数和类的定义都只存在于源码(代码段)中,而闭包和类对象则是在运行时占用内存空间的实体;

传参默认值

虽然理论上可以通过类似void f(bool x = true)来实现默认值。但是我在PTA和CPPRINTER上编译.so不过, 会出现undefied的符号。实际实现如下

// hpp
void SetDeterministic();
void SetDeterministic(bool isOpapi = true);

//cpp
void SetDeterministic() {
    SetDeterministic(true);
}
void SetDeterministic(bool isOpapi)
{
    //xxx
}

类型变参模板

template<typename T>
void swap(T& t1, T& t2)
{
    T temp = t2;
    t2 = t1;
    t1 = temp;
}
swap<int>(a,b);
条件模板类

假设我们有一个模板类 Wrapper,我们希望禁止 VirtualGuardImpl 类型作为模板参数:

template <
    typename T,
    typename U = T,
    typename = typename std::enable_if<!std::is_same<U, VirtualGuardImpl>::value>::type>
class Wrapper {
public:
    void function() {
        // 实现
    }
};

在这个例子中,如果用户尝试创建 Wrapper<VirtualGuardImpl>Wrapper<VirtualGuardImpl, VirtualGuardImpl> 的实例,编译器将报错,因为 std::enable_if 的条件不满足。但如果使用其他类型,比如 int 或自定义类型,就可以正常编译。

这段代码是 C++ 中的一个模板函数或模板类模板参数的定义,它使用了模板默认参数、std::enable_if 条件编译技术以及类型萃取(type traits)。下面是对这段代码的详细解释:

  1. 模板参数 U:

    • typename U = T 定义了一个模板类型参数 U,并给它一个默认值 T。这意味着如果在使用模板时没有指定 U 的话,它将默认使用模板参数 T 的值。
  2. std::enable_if:

    • std::enable_if 是一个条件编译技术,它只在给定的布尔表达式为 true 时启用某个模板。
    • 在这个例子中,std::enable_if 后面的布尔表达式是 !std::is_same<U, VirtualGuardImpl>::value。这意味着只有当 U 不等于 VirtualGuardImpl 类型时,这个模板参数才有效。
  3. typename 关键字:

    • typename 关键字用于告诉编译器 std::enable_if 的结果是一个类型。std::enable_if 返回的是一个类型,如果条件为 true,它返回一个空的类型,否则会导致编译错误。
  4. std::is_same:

    • std::is_same<U, VirtualGuardImpl>::value 是一个编译时检查,用于判断 UVirtualGuardImpl 是否是相同的类型。::value 是类型特征 std::is_same 的一个成员,它是一个布尔值,如果类型相同则为 true,否则为 false
  5. 组合解释:

    • 这段代码的意思是:定义一个模板参数 U,默认值为 T,并且这个模板参数只有在 U 不是 VirtualGuardImpl 类型时才有效。
    • 这是一种常见的模板编程技巧,用于约束模板参数的类型,以确保它们符合特定的要求。

个数变参模板

#include <stdarg.h>
void Error(const char* format, ...)
{
    va_list argptr;
    va_start(argptr, format);
    vfprintf(stderr, format, argptr);
    va_end(argptr);
}

VA_LIST 是在C语言中解决变参问题的一组宏,变参问题是指参数的个数不定,可以是传入一个参数也可以是多个;可变参数中的每个参数的类型可以不同,也可以相同;可变参数的每个参数并没有实际的名称与之相对应,用起来是很灵活。

(1)首先在函数里定义一具VA_LIST型的变量,这个变量是指向参数的指针; (2)然后用VA_START宏初始化变量刚定义的VA_LIST变量; (3)然后用VA_ARG返回可变的参数,VA_ARG的第二个参数是你要返回的参数的类型(如果函数有多个可变参数的,依次调用VA_ARG获取各个参数); (4)最后用VA_END宏结束可变参数的获取。

系统提供了vprintf系列格式化字符串的函数,用于编程人员封装自己的I/O函数。

int vprintf  / vscanf   (const char * format, va_list ap);                  // 从标准输入/输出格式化字符串 
int vfprintf / vfsacanf (FILE * stream, const char * format, va_list ap);   // 从文件流 
int vsprintf / vsscanf  (char * s, const char * format, va_list ap);        // 从字符串

返回多个数

使用结构体

struct RowAndCol { int row;int col; };

RowAndCol r(string fn) {
    /*...*/
    RowAndCol result;
    result.row = x;
    result.col = y;
    return result;
}

左值与右值

在 C++ 中,左值(lvalue)右值(rvalue) 是两个重要的概念,用来描述表达式的值和内存的关系。它们帮助开发者理解变量的生命周期、赋值和对象管理,特别是在现代 C++ 中引入了右值引用后,优化了移动语义和资源管理。

1. 左值(lvalue)

左值(lvalue,locatable value) 是指在内存中有明确地址、可持久存在的对象,可以对其进行赋值操作。通俗地说,左值是能够取地址的值,可以出现在赋值操作符的左边。

特点:

  • 左值具有持久的内存地址。
  • 左值可以取地址(使用 & 运算符)。
  • 左值通常表示已经存在的变量或对象。

示例

int x = 10;   // x 是左值
int* p = &x;  // 可以取 x 的地址

x = 20;       // 可以对左值进行赋值

在这个例子中,x 是一个左值,因为它表示了内存中的某个对象,并且可以通过赋值语句修改它的值。

2. 右值(rvalue)

右值(rvalue,readable value) 是没有明确地址、临时存在的对象,不能对其进行赋值操作。它们通常是字面值常量或表达式的结果。右值只能出现在赋值操作符的右边,表示一个临时对象或数据。

特点:

  • 右值是临时的,通常会在表达式结束时销毁。
  • 右值不能取地址(即不能使用 & 获取右值的地址)。
  • 右值表示表达式的计算结果或临时对象。

示例

int y = 10;       // 10 是右值
int z = y + 5;    // y + 5 是右值表达式

在这个例子中,10y + 5 是右值,因为它们表示计算出的临时数据,并且不能直接对这些值进行赋值操作。

3. 现代 C++ 中的右值引用(rvalue reference)

C++11 引入了 右值引用,即通过 && 符号表示。这使得右值也能通过引用进行操作,特别是在实现移动语义(move semantics)和避免不必要的拷贝时非常有用。右值引用允许我们通过右值管理资源,避免性能上的损失。

示例:右值引用与移动语义

#include <iostream>
#include <vector>

int main() {
    std::vector<int> vec1 = {1, 2, 3};
    std::vector<int> vec2 = std::move(vec1);  // vec1 资源移动到 vec2

    std::cout << "vec1 size: " << vec1.size() << std::endl;
    std::cout << "vec2 size: " << vec2.size() << std::endl;

    return 0;
}

在这个例子中,std::movevec1 变为一个右值引用,使其内部的资源(如动态分配的内存)直接转移给 vec2,避免了拷贝。

4. 区分左值与右值

通常,左值是表示持久存在的对象,可以通过取地址符 & 获取其地址,而右值是临时的、短暂存在的值,不能直接获取其地址。理解这两者对于编写高效的 C++ 代码和使用现代特性(如右值引用和移动语义)非常重要。

常见误区

  • 字面值常量(如 42、'a')是右值
  • 表达式的结果(如 x + y)通常是右值。
  • 函数返回值若返回的是值,而不是引用,则该返回值是右值。
  • 左值(lvalue) 是可以取地址的值,通常是变量或持久的对象。
  • 右值(rvalue) 是临时值,通常是表达式的结果或字面量。
  • 右值引用(&&)是 C++11 引入的新特性,用来优化资源管理和避免不必要的拷贝操作。

C++11: 花括号初始化列表

使用

在C++98/03中我们只能对普通数组和POD(plain old data,简单来说就是可以用memcpy复制的对象)类型可以使用列表初始化,如下:

//数组的初始化列表: 
int arr[3] = {1,2,3}
//POD类型的初始化列表:
struct A
{
 int x;
 int y;
}a = {1,2};

在C++11中初始化列表被适用性被放大,可以作用于任何类型对象的初始化。如下:

X x1 = X{1,2};
X x2 = {1,2}; // 此处的'='可有可⽆
X x3{1,2};
X* p = new X{1,2};

//列表初始化也可以用在函数的返回值上
std::vector<int> func() {
    return {};
}

变量类型的适用范围

聚合类型可以进行直接列表初始化

聚合类型包括

  1. 普通数组,如int[5],char[],double[]等
  2. 一个类,且满足以下条件:
    1. 没有用户声明的构造函数
    2. 没有用户提供的构造函数(允许显示预置或弃置的构造函数)
    3. 没有私有或保护的非静态数据成员
    4. 没有基类
    5. 没有虚函数
    6. 没有{}和=直接初始化的非静态数据成员
    7. 没有默认成员初始化器

原理

对于一个聚合类型,使用列表初始化相当于使用std::initializer_list对其中的相同类型T的每个元素分别赋值处理,类似下面示例代码;

struct CustomVec {
    std::vector<int> data;
    CustomVec(std::initializer_list<int> list) {
        for (auto iter = list.begin(); iter != list.end(); ++iter) {
            data.push_back(*iter);
        }
    }
};

优势

  1. 方便,且基本上可以替代括号初始化
  2. 可以使用初始化列表接受任意长度
  3. 可以防止类型窄化,避免精度丢失的隐式类型转换

参考文献

  1. https://zh.cppreference.com/

  2. https://leetcode-cn.com/problems/path-with-maximum-gold/solution/huang-jin-kuang-gong-by-leetcode-solutio-f9gg/

  3. https://blog.csdn.net/qq_33221533/article/details/82119031

  4. ⼩贺 C++ ⼋股⽂ PDF 的作者,电⼦书的内容整理于公众号「herongwei」

  5. https://blog.csdn.net/hailong0715/article/details/54018002

https://shaojiemike.notion.site/C-11-a94be53ca5a94d34b8c6972339e7538a