之前写过modern cpp学习,但是只是过了一遍文字. 最近我在使用c++重写karpathy的micrograd,学到了很多.这里记录一下重要的东西
模板黑魔法
黑魔法是常人无法参透的,这里仅作简单介绍
TODO:
模板特化
模板特化是C++模板机制中的一个重要特性,它允许程序员针对特定的数据类型或一组数据类型对模板进行定制。当编译器遇到一个特化的模板实例时,它会使用特化版本而不是通用模板版本。这可以用于优化特定类型的性能,处理不同数据类型之间的差异,或者实现完全不同的行为。
模板特化概述
假设你有一个模板函数identity
,它的作用是返回传入的参数本身:1
2
3
4template<typename T>
T identity(T x) {
return x;
}
基本模板
1 | template<typename T> |
函数模板特化
你可以为特定类型(如std::string
)特化这个模板:1
2
3
4
5
6
7
8template<>
std::string identity<std::string>(const std::string& s) {
// 可以添加一些特定于std::string的操作
// 例如,转换为大写
std::string result = s;
std::transform(result.begin(), result.end(), result.begin(), ::toupper);
return result;
}
在这个例子中,对于std::string
类型,identity
函数将返回一个全部字符转为大写的字符串,而对其他类型则保持原样。
类模板特化
类模板也可以被特化。例如,假设我们有一个类模板Box
,它可以存储任何类型的数据:1
2
3
4
5
6
7
8template<typename T>
class Box {
public:
void set(const T& value) { data = value; }
T get() const { return data; }
private:
T data;
};
我们可以为int
类型特化Box
类,以便为整数添加额外的功能,比如自动增加:1
2
3
4
5
6
7
8template<>
class Box<int> {
public:
void set(int value) { data = value + 1; } // 自动增加
int get() const { return data; }
private:
int data;
};
这样,Box<int>
的行为就与Box<T>
的通用版本不同了。
完全特化与部分特化
完全特化是指为模板的所有参数指定特定类型,如上面的例子所示。部分特化是指只指定模板的部分参数,通常用于多参数模板,例如:1
2
3
4
5
6
7
8template<typename T1, typename T2>
class Pair;
// 部分特化Pair<int, int>
template<>
class Pair<int, int> {
// ...
};
函数模板实例化
1 | template<typename T> |
限定类型
1 | template<typename T,typename = std::enable_if_t<std::is_arithmetic_v<T>>> |
c++20以上使用concept能够好做限制,这里不做详细介绍.Concepts library (since C++20) - cppreference.com
1 |
|
SFINAE与enable_if
使用 std::enable_if
和 SFINAE(Substitution Failure Is Not An Error,替代错误不是错误)来限定类型。这种方法允许你在模板的定义阶段就排除不符合要求的类型1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
template<typename T, typename = void>
class NumericWrapper {
private:
T data;
public:
NumericWrapper(T value) : data(value) {}
void setData(T value) {
data = value;
}
T getData() const {
return data;
}
};
template<typename T>
class NumericWrapper<T, std::enable_if_t<std::is_arithmetic_v<T>>> {
private:
T data;
public:
NumericWrapper(T value) : data(value) {}
void setData(T value) {
data = value;
}
T getData() const {
return data;
}
};
int main() {
NumericWrapper<int> intWrapper(10);
NumericWrapper<double> doubleWrapper(3.14);
// 下面的声明不会导致编译错误,但会生成一个空模板实例
NumericWrapper<std::string> stringWrapper("Hello"); // 不符合要求的类型
return 0;
}
使用场景
模板特化常用于:
- 为特定类型提供更高效的实现。
- 解决某些类型不适用的通用算法问题。
- 提供对基本类型和用户定义类型的统一接口,同时保持内部实现的差异性。
智能指针的使用场景
智能指针是C++中用来自动管理动态分配内存的一种手段,能帮助避免内存泄漏和其他与手动管理内存相关的问题.
智能指针本身也是指针,但是会通过编译器自动管理,表现行为像一个栈上的变量一样,在作用域之外就会自动析构.
std::unique_ptr
是一种独占所有权的智能指针,它保证了对所指向对象的独占访问。这意味着在同一时刻,只有一个std::unique_ptr
可以指向同一个对象。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main() {
// 使用new分配内存,并使用std::unique_ptr管理
std::unique_ptr<int> uptr(new int(10));
// 使用std::make_unique简化创建过程
std::unique_ptr<int> uptr2 = std::make_unique<int>(20);
// 访问智能指针所指向的对象
std::cout << "uptr points to: " << *uptr << std::endl;
std::cout << "uptr2 points to: " << *uptr2 << std::endl;
// unique_ptr在离开作用域时自动释放内存
return 0;
}
std::shared_ptr
允许多个指针共享同一对象的所有权。当最后一个指向该对象的std::shared_ptr
销毁时,对象的内存会被释放1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main() {
// 创建一个shared_ptr
std::shared_ptr<int> sptr1 = std::make_shared<int>(10);
// 从sptr1复制所有权
std::shared_ptr<int> sptr2 = sptr1;
// 访问智能指针所指向的对象
std::cout << "sptr1 points to: " << *sptr1 << std::endl;
std::cout << "sptr2 points to: " << *sptr2 << std::endl;
// shared_ptr在引用计数变为0时释放内存
return 0;
}
std::weak_ptr不增加引用计数,它用于观察std::shared_ptr所管理的对象,而不会影响对象的生命周期。当std::shared_ptr不再存在时,std::weak_ptr可以被用来检查对象是否还活着,并锁定一个std::shared_ptr。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int main() {
// 创建一个shared_ptr
std::shared_ptr<int> sptr = std::make_shared<int>(10);
// 创建一个weak_ptr
std::weak_ptr<int> wptr = sptr;
// 检查weak_ptr是否过期
if (!wptr.expired()) {
std::shared_ptr<int> sptr2 = wptr.lock(); // 锁定一个shared_ptr
std::cout << "wptr points to: " << *sptr2 << std::endl;
}
// 释放shared_ptr,观察weak_ptr的行为
sptr.reset();
// 再次检查weak_ptr是否过期
if (wptr.expired()) {
std::cout << "The object has been deleted." << std::endl;
}
return 0;
}
模板类的友元函数重载<<符号
给一个模板类写个友元函数重载<<
方便ostream输出信息,但是报错链接出错.
函数模板和友元重载运算符报”无法解析的外部符”的解决方法_输出运算符重载 无法解析-CSDN博客
两次编译的函数头不一样,因为友元函数并不属于类的成员函数,所以需要单独声明此友元函数是函数模板,如果没有声明,但是后面在实现的时候又使用了template <class T>
,就会导致错误的发生。
右值和移动
Use std::move(x) to turn x, an l-value, to an r-value so that you can immediately take its resources
泛左值(“泛化 (generalized)”的左值)是一个求值可确定某个对象或函数的标识的表达式;
纯右值
(“纯 (pure)”的右值)是求值符合下列之一的表达式:
- 计算某个运算符的操作数的值(这种纯右值没有结果对象)
- 初始化某个对象(称这种纯右值有一个结果对象)
亡值(“将亡 (expiring)”的值)是代表它的资源能够被重新使用的对象或位域的泛左值;
- 左值 (lvalue) 是并非亡值的泛左值;
移动语义允许在对象从一个位置移动到另一个位置时,通过移动而非复制对象的状态,从而避免了昂贵的复制操作。这在处理大型对象或资源(如文件句柄、智能指针)时尤其重要,因为移动语义可以显著提升性能。
右值通常用来表示临时对象或字面量,这些对象在表达式求值后就不再需要。例如,函数返回的临时对象、构造函数参数中的字面量等都是右值
右值引用用于实现移动语义
OOP in C++
C++中的面向对象设计也许并不好或者说糟糕
编译器会为每个类默认生成特别方法,包括
- 无参构造
- 拷贝构造
- 拷贝赋值
- 移动构造
- 移动赋值
- 析构方法
如果类中的变量没有人为内存的分配,也许你并不需要显式声明拷贝、移动与析构方法.
当声明拷贝构造、赋值时,最好声明移动构造、赋值以及析构方法.
std::move
在移动构造、赋值中使用,表明需要使用移动操作,而不要在main中使用.
Type Safety
Type Safety: The extent to which a language prevents typing errors
使用std::optional
Beyond C++2a
Concept
1 | concept Addable = requries(T a, T b) { |
限制泛型中参数的类型.
Module
目前我认为模块机制在c++生态用得不是很多,权当了解即可.1
2export module myModule
export void sayHello(){}1
2
3
4import myModule
int main(){
sayHello();
}
Modules in C++ 20 - GeeksforGeeks
std::ranges
引入头文件#include<ranges>
和#include<algorithms>
常用的一些算法通常是对迭代器进行操作(STL)1
2std::sort(v.begin(),v.end());
std::sort(v.begin()+1,v.end());
在引入ranges后,有了更加统一的方法
ranges是“项的集合”或“可迭代的东西”的抽象。最基本的定义只要求在ranges上存在begin()和end()
1 | std::ranges::sort(std::views::drop(v,5)); |
有多个关于ranges的concept
Concept | Description |
---|---|
std::ranges::input_range | can be iterated from beginning to end at least once |
std::ranges::forward_range | can be iterated from beginning to end multiple times |
std::ranges::bidirectional_range | iterator can also move backwards with -- |
std::ranges::random_access_range | you can jump to elements in constant-time [] |
std::ranges::contiguous_range | elements are always stored consecutively in memory |
std::forward_list | std::list | std::deque | std::array | std::vector | |
---|---|---|---|---|---|
std::ranges::input_range | ✅ | ✅ | ✅ | ✅ | ✅ |
std::ranges::forward_range | ✅ | ✅ | ✅ | ✅ | ✅ |
std::ranges::bidirectional_range | ✅ | ✅ | ✅ | ✅ | |
std::ranges::random_access_range | ✅ | ✅ | ✅ | ||
std::ranges::contiguous_range | ✅ | ✅ |
views的一个关键特性是,无论它们应用了什么转换,它们都是在请求元素的时候进行的,而不是在创建views的时候1
2
3
4std::vector vec{1, 2, 3, 4, 5, 6};
auto v = vec | std::views::reverse | std::views::drop(2);
std::cout << *v.begin() << '\n';
views是一种特定类型的ranges,在std::ranges::view中被形式化。
C++20之后定义比较运算符
为了检查是否相等,现在定义 == 操作符就够了。 当编译器找不到表达式的匹配声明 a!=b 时,编译器会重写表达式并查找!(a\==b)。若这不起作用,编译器也会尝试改变操作数的顺序,所以也会尝试!(b==a)1
2
3
4
5
6
7bool operator==(const TypeA&, const TypeB&);
//
struct NullTerm {
bool operator== (auto pos) const {
return *pos == '\0'; // end is where iterator points to \verb+'\0'+
}
};
对于所有的关系操作符,没有等价的规则说定义小于操作符就足够了。但现在,只需要定义新的操作符 <=> 即可。
通常,== 可以通过定义 == 和!= 操作符来处理对象的相等性,而 <=> 操作符通过定义关系操作 符来处理对象的顺序。若通过 =default 声明操作符 <=>,则可以使用了一个特殊的规则,即默认成员操作符 <=>:
• 若比较成员不抛出异常,则是 noexcept
• 若可在编译时比较成员,则是 constexpr
• 因为重写,还可以支持第一个操作数的隐式类型转换
通常情况下,== 和 <=> 操作符处理不同但相关的事情: • == 操作符定义相等性,可由相等操作符 == 和!= 使用。 • <=> 操作符定义了排序,可以由关系操作符 <、<=、> 和 >= 使用
auto类型推断
c++20之后,在普通函数中即可使用.
FAQ
static,inline和extern. 链接类型
链接使用linker将多个编译得到.o文件链接为可执行程序. 多个链接单元不允许重复定义的变量或函数等.
inline,避免多次定义
inline 修饰的函数具有外部链接属性(externallinkage)。在链接时,只会保留一个定义。C++17 引入了内联变量(inline variable)的概念,允许在头文件中定义变量而不会违反 One Definition Rule(ODR)。
要声明内联变量,可以在变量声明前加上 inline 关键字。这告诉编译器允许多个编译单元中都有这个变量的定义,而不会引发 ODR 错误
static,内部链接属性(internal linkage),修饰的全局变量的作用域仅限于定义它的文件。这意味着其他文件无法访问该变量,使用 static 可以避免命名冲突,因为每个源文件中的 static变量是独立的,即使它们同名也不会互相干扰
extern,用于声明一个全局变量或函数,表明该变量或函数在其他文件中定义。它允许在一个文件中使用另一个文件中定义的变量
当 extern 用于声明一个变量或函数时,它指定该符号具有外部链接属性
通常在头文件中定义/声明全局变量,以便其他源文件可以包含该头文件并使用这些变量
在多个文件中重复定义同名的 extern 变量,会导致链接错误
外部链接属性意味着该符号可以被程序中的其他翻译单元访问。
对于函数,默认情况下它们就具有外部链接属性,无需使用 extern 关键字。
在 C++ 中,类的成员方法如果在类的定义中直接实现,则默认是 inline 的.内联函数具有外部链接属性,是弱符号。在类外实现默认不是inline,对应强符号。
一般将不需要内联的成员函数的定义编写在.cpp文件中,这样可以避免此类错误,这样多个其他文件引入头文件时不会造成重定义.
模板声明和定义放一个文件的目的.
在使用模板时,这种习惯性做法将变得不再有用,因为当实例化一个模板时,编译器必须看到模板确切的定义,而不仅仅是它的声明。因此,最好的办法就是将模板的声明和定义都放置在同一个.h文件
因为在编译时模板并不能生成真正的二进制代码,而是在编译调用模板类或函数的CPP文件时才会去找对应的模板声明和实现,在这种情况下编译器是不知道实现模板类或函数的CPP文件的存在,所以它只能找到模板类或函数的声明而找不到实现,而只好创建一个符号寄希望于链接程序找地址。但模板类或函数的实现并不能被编译成二进制代码,结果链接程序找不到地址只好报错了。
参考资料
- Learn C++ – Skill up with our free tutorials (learncpp.com)
- cplusplus.com
- cppreference.com
- 现代 C++ 教程: 高速上手 C++ 11/14/17/20 - Modern C++ Tutorial: C++ 11/14/17/20 On the Fly (changkun.de)
- 首页 | 谷雨同学的 C++ 教程 (guyutongxue.site)
- CS 106L: Standard C++ Programming (stanford.edu)
- Best Practiceshttps://lefticus.gitbooks.io/cpp-best-practices/content
cpp书单c++ faq - The Definitive C++ Book Guide and List - Stack Overflow
感谢大模型的辅助