文章目录
构造函数
在C++中,构造函数分为以下几类:
默认构造函数:不带参数的构造函数,如果没有定义则会自动创建。例如:
class MyClass{
public:
MyClass(){
//默认构造函数
}
};
带参数构造函数:带有参数的构造函数,用于初始化类中的成员变量。例如:
class Point{
public:
Point(int x, int y){
m_x = x;
m_y = y;
}
private:
int m_x;
int m_y;
};
拷贝构造函数:用于将一个已存在的对象复制到一个新的对象中。例如:
class String{
public:
String(const String& str){
size_t size = strlen(str.m_str) + 1;
m_str = new char[size];
memcpy(m_str, str.m_str, size);
}
private:
char* m_str;
};
移动构造函数:用于将一个临时对象的资源转移给另一个对象,在C++11中引入。例如:
class A{
public:
A(A&& a){
//将a的资源转移给当前对象
m_data = a.m_data;
a.m_data = nullptr;
}
private:
int* m_data;
};
虚构造函数:用于对象被销毁时的清理工作,也叫析构函数。例如:
class MyClass{
public:
MyClass(){
//构造函数
}
~MyClass(){
//析构函数
}
};
拷贝构造函数中,深拷贝和浅拷贝区别
浅拷贝是指仅仅复制指针指向的内存地址,而不是复制实际指针指向的内存里的内容。这意味着,在源对象和目标对象之间共享同一个指针指向的内存区域,可能会导致两个对象数据相互影响的问题。当源对象和目标对象生命周期不同时,可能会导致一些未定义的行为,例如当销毁其中一个对象时,由于指向同一内存地址的指针只有一个被释放,而另一个指针仍然指向那个不再安全的内存地址。
相反,深拷贝会复制指针指向的内存区域,而非仅仅复制指针本身,从而在源对象和目标对象之间创建出两块独立的内存区域,彼此互不干扰。
下面通过一个示例来说明二者之间的区别:
class A {
public:
A(int size) {
size_ = size;
data_ = new int[size_];
}
A(const A& other) { // 拷贝构造函数,实现了浅拷贝
size_ = other.size_;
data_ = other.data_;
}
~A() { delete[] data_; }
private:
int* data_;
int size_;
};
int main()
{
A a1(10);
A a2(a1); // 调用拷贝构造函数
return 0;
}
在上述示例中,我们定义了一个类A,包含一个指向int数组的指针,初始化时分配了指定大小的内存空间。在拷贝构造函数中,我们仅仅复制了成员变量指针的值,而不是拷贝指针指向的内存。这就是一个浅拷贝。这样做可能会导致在销毁类实例时内存泄漏的问题,因为同一块内存空间可能不止一个指针指向它。如果我们进行了一次修改,那么另外的对象同样也会受到影响:
int main()
{
A a1(10);
a1.data_[0] = 42;
A a2(a1); // 调用拷贝构造函数
std::cout << a2.data_[0] << std::endl; // 输出 42
return 0;
}
如果我们使用深拷贝方法,那么每个实例都有自己独立的内存空间,就可以避免这个问题。
class A {
public:
A(int size) {
size_ = size;
data_ = new int[size_];
}
A(const A& other) { // 拷贝构造函数,实现了深拷贝
size_ = other.size_;
data_ = new int[size_];
memcpy(data_, other.data_, size_ * sizeof(int));
}
~A() { delete[] data_; }
private:
int* data_;
int size_;
};
int main()
{
A a1(10);
a1.data_[0] = 42;
A a2(a1); // 调用拷贝构造函数
std::cout << a2.data_[0] << std::endl; // 输出 0
return 0;
}
在这个实现中,我们使用了深拷贝方式来拷贝data_指向的数组数据,这样每个实例都有自己的一个独立拷贝。这可以防止多个对象共享同一块内存空间造成的潜在问题。
左值引用和右值引用
在 C++ 语言中,一个表达式可以产生值,或者作为左值(lvalue)被赋值。左值是一个代表内存中可识别位置的表达式。右值(rvalue)指的是在表达式计算后是立即可丢弃的值。对于左值引用(lvalue reference)和右值引用(rvalue reference),可以简答地定义如下:
左值引用(lvalue reference)绑定到一个左值(lvalue),通常用于传递普通变量和对象的引用,对左值引用进行解引用操作可以获得它指向的对象。
右值引用(rvalue reference)绑定到一个右值(rvalue),即一个临时的、无法被其他表达式所引用的值,通常用于实现移动构造函数和移动赋值运算符等。
在 C++11 之前,我们只有左值引用,没有右值引用,因此很多操作都只能通过复制或者传递指针的方式来实现。C++11 引入了右值引用,可以进行更高效的操作,实现了移动的语义,避免了不必要的复制和内存分配。
左值引用和右值引用的语法区别如下:
左值引用用 & 符号表示,例如:int& a = i;
右值引用用 && 符号表示,例如:int&& a = 3;
在对于右值引用的赋值操作中,移动构造函数和移动赋值运算符是常见的应用场景。当使用复制构造函数或者赋值构造函数时,我们会将原对象的数据复制到新创建的对象中,这可能会涉及到一些内存重新分配和复制操作。相反,移动构造函数和移动赋值运算符可以将原对象中的数据成员指针等资源交给新对象接管,从而避免了不必要的开销。
右值引用
c++右值引用中,移动语义和完美转发是什么,移动构造函数(Move Constructor)和移动赋值运算符是什么,怎么使用的,有什么区别
右值引用(Rvalue reference)是C++11引入的一个特性,它允许对右值进行引用。在这里,我们可以讨论两个右值引用的相关概念:移动语义和完美转发。
移动语义就是在需要把数据从一个对象(实际上是右值)转移到另一个对象而不是复制数据的情况下,可以通过使用移动构造函数(Move Constructor)和移动赋值运算符(Move Assignment Operator)来实现效率更高的数据转移。在这种情况下,如果只是使用普通的复制操作来完成,那么会产生几乎相同的数据拷贝,这样会浪费CPU和内存资源。如果使用移动构造函数或移动赋值运算符,在不产生任何新的额外拷贝和内存分配的情况下,将数据从一个右值对象转移到另一个对象,可以大大提升程序的性能和效率。
移动构造函数和移动赋值运算符都是成员函数。移动构造函数用于从右值对象中“抽取”资源,并将其转移给被构造的新对象。移动赋值运算符执行类似的操作,但它工作在已存在的对象上,而不是新对象。它会释放已存在对象的资源,并将另一个对象的资源“转移”给它。函数原型如下:
class A {
public:
A(A&& other); // 移动构造函数
A& operator=(A&& other); // 移动赋值运算符
};
其中,A&&是一个右值引用类型。移动构造函数和移动赋值运算符的参数是被搬移的对象的右值引用,可以通过使用std::move将对象的左值转换为右值。在移动函数内部,可以像复制构造函数一样通过此引用来直接访问原来的对象的数据。然后将对象数据的指针或其他重要的指针(例如文件指针)转移给新的对象,同时也将原始对象的指针释放。
完美转发是一种技术,用于在不失去数据的情况下将函数参数传递给另外一个函数。它可以通过一种模板元编程技术来实现,称为“转发引用”(Forwarding Reference)。完美转发具有的特性包括消除函数重载的需要、避免额外的对象的复制或移动和避免数据损失。完美转发通常使用右值引用作为转发引用的类型。
对于移动构造函数,我们来看一下一个简单例子:
#include <iostream>
#include <utility>
class A
{
int* ptr_;
public:
A(int* p = nullptr) : ptr_(p) {}
~A() { delete ptr_; }
A(A&& other) noexcept // 移动构造函数
{
ptr_ = other.ptr_;
other.ptr_ = nullptr;
}
int* get_ptr() const { return ptr_; }
};
int main()
{
A a1(new int(42));
A a2 = std::move(a1);
std::cout << "a1.get_ptr() = " << a1.get_ptr() << '\n';
std::cout << "a2.get_ptr() = " << a2.get_ptr() << '\n';
return 0;
}
在上述示例中,我们定义了一个类A,它有一个名称为ptr_的int型指针成员变量。我们定义了一个类的实例a1,它包含一个指向动态分配的int对象的指针并将其初始化为42。我们之后调用std::move(a1),它将a1的内部指针“转移”到了a2中的指针,同时a1中的指针被置为nullptr。由于移动构造函数没有复制数据,所以a2中的指针指向了a1中的那个int对象,而a1中的指针为nullptr。
对于完美转发,我们来看一个例子:
#include <iostream>
void foo(int& x)
{
std::cout << "foo(int&): x = " << x << '\n';
}
void foo(const int& x)
{
std::cout << "foo(const int&): x = " << x << '\n';
}
void foo(int&& x)
{
std::cout << "foo(int&&): x = " << x << '\n';
}
template<typename T>
void bar(T&& x)
{
foo(std::forward<T>(x));
}
int main()
{
int i = 42;
bar(i); // foo(int&): x = 42
bar(42); // foo(int&&): x = 42
const int ci = 42;
bar(ci); // foo(const int&): x = 42
return 0;
}
在上述示例中,我们定义了三个重载函数:一个接受一个左值引用(int&),一个接受一个常量左值引用(const int&),一个接受一个右值引用(int&&)。我们还定义了一个模板函数bar,它接收一个模板类型参数T(右值引用类型),在函数中通过std::forward将函数参数完美转发到foo函数中。在main函数中,对于不同的参数类型,我们调用bar函数,从而可以验证不同类型的foo函数被调用。
需要注意的是,在模板函数中,我们必须使用std::forward来保证引用的类型是准确的。如果我们使用std::move而不是std::forward,则有可能将一个带有左值引用的参数(例如bar(i))转换为右值引用类型,这可能会发生类型不匹配的问题。
多线程
多线程库
在 C++ 中,常用的多线程库有以下几种:
POSIX Threads
也称为 pthreads,是一个公认的标准多线程 API。它是跨平台的,在 Unix/Linux 和 Windows 上都可以使用。使用 pthreads,你需要包含 <pthread.h> 头文件,并使用 pthread_create()、pthread_join()、pthread_mutex_t 等函数和类型。
C++11 Thread Library
是 C++11 引入的一个标准多线程库,它包含在 、、<condition_variable> 等头文件中。C++11 Thread Library 可以高效地创建线程、保护共享数据、进行线程间通信等,并且简化了代码的编写。
举例如下:
#include <iostream>
#include <thread>
using namespace std;
void hello() {
cout << "Hello, world!\n";
}
int main() {
thread t(hello);
t.join();
}
OpenMP
是一个并行编程 API,它提供了一套指令、库和编译器扩展,可以用于在共享内存计算机上并行化程序。OpenMP 提供了一种简单的方法来管理线程、任务和数据,可以使得并行化程序变得简单易懂。
举例如下:
#include <iostream>
#include <omp.h>
using namespace std;
int main() {
#pragma omp parallel
{
int id = omp_get_thread_num();
cout << "Hello, world! Thread id = " << id << endl;
}
return 0;
}
需要注意的是,上述三种多线程库都是并发编程的高级技术,在使用时需要特别小心。
数据保护
在使用OpenMP加速for循环时,保护某个数据的方式有多种。以下是几种常见的方式:
临界区(critical):使用#pragma omp critical语句来保护临界区内的代码,确保同一时间只有一个线程可以执行临界区内的操作。这是最常见的方式,但也具有较大的同步开销。
int sum = 0;
#pragma omp parallel for
for (int i = 1; i <= 100; i++) {
#pragma omp critical
sum += i;
}
原子操作(atomic):使用#pragma omp atomic语句来保护对变量的原子操作,确保同一时间只有一个线程可以修改该变量。原子操作的开销较小,适用于对单个变量的简单操作。
int sum = 0;
#pragma omp parallel for
for (int i = 1; i <= 100; i++) {
#pragma omp atomic
sum += i;
}
归约(reduction):使用reduction指定要进行归约操作的变量,OpenMP会自动将每个线程的私有副本合并成最终的结果。归约适用于对可结合和可交换的操作,如加法和乘法。
int sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 1; i <= 100; i++) {
sum += i;
}
其中,归约操作的符号(如+、*)应根据具体的运算进行相应的选择。
在上述几种方式中,reduction通常是最好的选择,因为它能够减少同步开销并提高并行性能。但在某些情况下,临界区和原子操作也是很有用的,特别是当需要对复杂的操作进行保护时。在实际应用中,应根据具体的情况选择最合适的方式。
注意事项
使用时应该是,先#pragma omp parallel { }
,会对{}内的代码块进行并行执行,一般是和for循环引用,因此在{}内部中for循环前面加上#pragma omp for
第二种方式,直接使用#pragma omp parallel for{ for循环 }
,这种情况也可以,相当于上一种方案合在一起。不可以单独使用#pragma omp for
,这样实际上没有使用omp,有parallel命令才会开辟一个并行区域。
线程锁
在C++中,经常使用以下几个线程库:
-
std::thread:C++11标准库中的线程库,能够创建线程、加入线程、分离线程等。
-
std::mutex:C++11标准库中的互斥锁,用于保护共享数据,防止多个线程同时对它进行访问。
以下是对应的线程锁使用例子:
- std::lock_guard:一个方便的RAII封装,用于管理线程锁的生命周期。当创建std::lock_guard对象时,它会自动加锁,并在对象销毁时自动解锁。例如:
std::mutex mtx;
void func()
{
std::lock_guard<std::mutex> lock(mtx);
// 此处进行对共享数据的读写操作
}
- std::unique_lock:C++11中的一种比std::lock_guard更加灵活的锁管理机制。对于std::unique_lock,可以手动加锁和解锁,并且还支持锁的延迟加锁和递归加锁等。例如:
std::mutex mtx;
void func()
{
std::unique_lock<std::mutex> lock(mtx);
// 此处进行对共享数据的读写操作
lock.unlock(); // 手动解锁
// 此处进行一些非关键区操作
lock.lock(); // 重新加锁
// 此处继续进行关键区操作
}
- std::condition_variable:C++11中的一种条件变量,用于线程间的通信。例如:
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
// 线程B等待直到线程A将ready的值设置为true
void threadB()
{
std::unique_lock<std::mutex> lock(mtx);
while (!ready) {
cv.wait(lock); // 等待条件变量
}
// 此处可以对共享数据进行读取操作
}
// 线程A设置ready的值为true,并通知所有因条件变量而阻塞的线程
void threadA()
{
// 此处对共享数据进行修改
ready = true;
cv.notify_all(); // 发送通知
}
容器类别
在C++中,容器是一种数据结构,用于存储和管理元素的集合。C++标准库提供了多种容器,其中最常用的是顺序容器和适配器容器。
顺序容器(Sequence Containers): 顺序容器是一种线性存储结构,其中的元素按照它们在容器中的位置进行排序。C++标准库提供了以下几种顺序容器:
vector:动态数组,支持快速随机访问。
list:双向链表,支持高效的插入和删除操作。
deque:双端队列,支持快速的随机访问和头尾插入/删除操作。
array:固定大小的数组,不支持动态扩展。
适配器容器(Adapter Containers): 适配器容器是通过将已有的一类容器(顺序容器或关联容器)包装起来,改变其接口形式而产生的容器。C++标准库提供了以下几种适配器容器:
stack:后进先出(LIFO)的堆栈,基于底层容器(默认是deque)。
queue:先进先出(FIFO)的队列,基于底层容器(默认是deque)。
priority_queue:具有优先级的队列,基于底层容器(默认是vector)。
区别:
数据结构:顺序容器是线性存储结构,元素按照位置进行排序;适配器容器是通过包装现有容器而获得的,并提供了新的接口形式。
功能和特性:顺序容器提供了丰富的功能和特性,如随机访问、插入/删除等;适配器容器提供了特定的功能(堆栈、队列、优先队列)或限制了可用的操作(只能在顶部插入/删除元素)。
底层容器:适配器容器基于底层容器实现,可以选择不同的底层容器来满足特定的需求;顺序容器通常使用自有的机制来管理元素存储。
总的来说,顺序容器更灵活,提供了更多的功能和特性;适配器容器则是对现有容器进行封装,提供了特定的行为方式。选择使用哪种容器取决于具体的需求和场景。
堆和栈区别
堆和栈都是计算机内存的不同区域。它们最大的区别在于它们的分配方式和使用方式。
分配方式
栈是由操作系统自动分配和管理的一段连续内存空间,用于存储函数调用时的临时变量和函数执行的上下文。栈上的内存分配和释放非常快速,是因为只需要将栈指针向上或向下移动相应的字节数即可。
堆是由开发人员手动分配和释放的不连续内存空间,用于存储程序运行过程中动态分配的一些数据。由于堆的内存分配和释放需要更加复杂的操作,所以相对比较慢。
使用方式
栈区域的内存在函数调用时建立,在函数调用结束后自动释放,因此栈上的内存空间不能被长期占用。栈中的内存空间大小通常为几百KB到1MB。
堆内存的使用方式比较灵活,程序可以手动分配和释放堆内存空间。在需要存储大量数据或需要长期保存数据时,开发人员通常会使用堆内存来存储数据。堆内存通常可以存储数GB到数TB的数据。
总的来说,堆和栈的主要区别在于分配方式和使用方式。如果数据需要长期保存或者需要存储大量数据,开发人员通常会使用堆内存。如果数据只需要在函数执行期间临时存储,开发人员通常会使用栈内存。
子类父类的构造函数与析构函数
子类和父类都有自己的构造函数和析构函数,它们在继承关系中的调用顺序有一定的区别。
当子类对象被创建时,它的构造函数会首先调用父类的构造函数,然后再调用自己的构造函数。当子类对象被销毁时,它的析构函数会首先调用自己的析构函数,然后再调用父类的析构函数。
这种调用顺序确保了父类部分和子类部分都能够被正确地初始化和销毁。
完美转发以及forward使用
完美转发是指在函数中将参数按原样转发给另一个函数,从而避免了从一个函数到另一个函数的多处参数拷贝。而 std::forward 则是用于实现完美转发的标准库函数。
C++中的forward是一个用于完美转发(perfect forwarding)的函数模板,它通常用于将函数参数传递给另一个函数,同时保持参数的值类别(value category)(左值、右值),例如左值引用、右值引用等。
使用forward的语法如下:
template<class T>
constexpr T&& forward(typename std::remove_reference<T>::type& arg) noexcept;
template<class T>
constexpr T&& forward(typename std::remove_reference<T>::type&& arg) noexcept;
其中,第一个函数模板接收一个左值引用参数,第二个函数模板接收一个右值引用参数。remove_reference用于剥离参数的引用,保留它的值类型(value type)。
示例代码如下:
#include <iostream>
#include <utility>
void func(int& i)
{
std::cout << "lvalue reference" << std::endl;
}
void func(int&& i)
{
std::cout << "rvalue reference" << std::endl;
}
template<typename T>
void wrapper(T&& arg)
{
func(std::forward<T>(arg));
}
int main()
{
int n = 1;
wrapper(n); // lvalue reference
wrapper(1); // rvalue reference
wrapper(std::move(n)); // rvalue reference
return 0;
}
在示例代码中,函数func是一个接收参数的函数,它区分了左值引用和右值引用。
函数wrapper调用了func函数,并使用forward函数模板完美地转发了参数。无论参数是左值引用还是右值引用,在wrapper函数加上std::forward后,func函数都会正确地传递参数类型。因此,当使用std::forward时,可以确保将参数完美地传递给另一个函数,并保留原参数的值类型。
resrve函数和resize
C++中,reserve()函数和resize()函数都是用于对容器进行大小操作的。
reserve()函数用于修改容器容量的大小,但不会对容器内的元素个数造成影响,也不会清空容器。例如,如果我们要向vector容器中添加100个元素,可以使用reserve()函数预留空间,避免不必要的重复分配内存而提高性能。如果预留不够,则会再次扩充空间。
示例代码:
vector<int> v;
v.reserve(100); // 预留100个元素的空间
for (int i = 0; i < 100; i++) {
v.push_back(i); // 在尾部插入元素
}
resize()函数用于修改容器的大小,并根据需要进行元素的插入和删除。如果我们将容器的大小从原来的5个元素修改为10个元素,那么新添加的5个元素将会默认进行值初始化,也就是说如果是int类型的数组,新添加的5个元素会初始化为0。
示例代码:
vector<int> v(5, 1); // 创建一个大小为5,元素都为1的vector
v.resize(10); // 将vector大小修改为10,并在后面添加5个元素,值默认为0
需要注意的是,如果我们将容器缩小到一个比原来小的大小,那么超出新大小的元素将会被截断。例如,如果我们将上面的v修改为只有3个元素,那么后面的元素将会被截断。
普通指针和智能指针
C++中指针是一个非常重要的概念,它可以用来访问内存地址,对内存进行操作。
普通指针
是一种直接访问内存地址的指针,它需要手动分配和释放内存,如果使用不当会出现内存泄漏等问题。例如:
int* ptr = new int; //分配动态内存
*ptr = 10;
delete ptr; //释放内存
智能指针
而智能指针是一种可以自动管理内存的指针,它会自动释放已经分配的内存,从而避免内存泄漏等问题。C++11开始提供了std::unique_ptr和std::shared_ptr两种智能指针。
unique_ptr
std::unique_ptr是一种独占所有权的智能指针,也就是说一个对象同时只能有一个std::unique_ptr指向它,不能进行复制或赋值操作。当std::unique_ptr被删除或超出作用域时,它所管理的对象也会被自动释放。
std::unique_ptr<int> ptr(new int);
*ptr = 10; //指针使用
//std::unique_ptr会在其作用域结束自动释放内存
shared_ptr
std::shared_ptr是一种基于引用计数的智能指针,多个std::shared_ptr可以指向同一个对象,由引用计数来管理对象的生命期。当最后一个std::shared_ptr被删除或超出作用域时,它所管理的对象也会被自动释放。
std::shared_ptr<int> ptr1(new int);
std::shared_ptr<int> ptr2 = ptr1; //对象引用计数加1
*ptr1 = 10; //指针使用
//当ptr1和ptr2都超出作用域时,对象会被自动释放
shared_ptr能跨线程使用吗
首先,std::shared_ptr本身可以跨线程传递和使用,但是需要遵守以下规则:
当一个std::shared_ptr对象从一个线程中传递到另一个线程中时,需要使用std::shared_ptr的自定义删除器,以确保在共享指针的最后一个引用被销毁时,对象能够被正确删除。
在多线程环境下,要确保对std::shared_ptr的访问是并发安全的,可以考虑使用锁,例如std::mutex。
使用std::shared_ptr的时候要注意控制引用计数,以防止出现循环引用,导致内存泄漏。
其次,需要避免在跨线程中同时访问同一个对象,因为这可能会导致未定义行为。为了避免这种情况,可以使用线程安全的容器,例如std::shared_mutex和std::atomic等。
总之,std::shared_ptr可以跨线程使用,但需要遵守一些规则和注意事项,以确保多线程环境下的并发安全。
小知识
void* input_ptr_
void* input_ptr_
是一个使用了void*
类型的变量,其作用是在C++函数中接受任意类型的指针作为输入。由于void*
不指定类型,因此在函数中如果需要使用该指针指向的具体数据,通常需要进行类型转换。