C/C++面试题学习记录

本文主要参考:牛客网给的链接
同时本人在此基础上加入个人理解和相关例子,若有不对,恳请指出。

1. 语言基础

1.1 值初始化问题

默认初始化发生在下面的情形中:
T obj;	// 栈中的变量
 
new T;	// 堆的
  • 定义一个变量但是没有给出初始值.
  • 用 new 创建一个对象, 但是没有给出初始值.(提供了括号就认为提供了初值)
  • 当父类或非静态数据成员没有出现在构造函数初始化列表中时。

此时其效果为:

  • 如果变量是类类型, 就调用默认构造函数进行初始化
  • 如果变量是数组类型, 就对每个成员进行默认初始化
  • 否则, 不做任何事. 此时变量的值是不确定的。

定义数组时, 如果不给出初始值列表, 数组的值是不定的, 如果给出的初始值列表长度小于数组长度,甚至是0, 也会使得没有给出初值的部分被值初始化。这里看一下代码,可以更好理解:

void test() {
  int arr1[10];  // 值不确定
  int* p1 = new int[10];  // 仅分配内存,没有初始化
  int* p2 = new int[10]();  //会进行值初始化
  int arr2[5]{1,2};
  for (auto ele : arr2) {
    cout << ele << " ";
  }
  cout << endl;
}
//  省略main函数
//  g++ 2_default_initialize.cpp -std=c++11
// ./a.out  
// 1 2 0 0 0 

总结来说,在声明变量时,如果带(),变量就会进行默认值初始化;如果不带(),除了内置类型(如int)不会默认值初始化外,其他类型都会调用默认构造函数进行值初始化。

1.2 const和constexpr的区别

1.3 volatile

计算机有时会将变量缓存到寄存器中, 而不从内存获取变量的值, 有时候这可能会带来错误.
volatile 将会阻止这样的优化, 使得每次访问(读, 写, 成员调用)值时都会访问内存,把内存的数字重新存到寄存器上。
可以定义 const volatile 变量, const 表明程序不能修改变量, 但是外部设备可能会修改变量.
例如只读寄存器, 程序不可以写, 但是 CPU 可能会更新其值。
当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。例如:

volatile int i=10;
int a = i;
int b = i; 

volatile 指出 i 是随时可能发生变化的,每次使用它的时候必须从 i的地址中读取,因而编译器生成的汇编代码会重新从i的地址读取数据放在 b 中。而优化做法是,由于编译器发现两次从 i读数据的代码之间的代码没有对 i 进行过操作,它会自动把上次读的数据放在 b 中。而不是重新从 i 里面读。这样以来,如果 i是一个寄存器变量或者表示一个端口数据就容易出错,所以说 volatile 可以保证对特殊地址的稳定访问。
更详细的解释可以看这篇文章

1.4 mutable

  • 用于修饰类的非静态数据成员, 成员不能是引用, 不能是 const.
  • 表明即使类对象是常量, 或通过常量引用访问该数据成员, 或在 const 函数中访问该成员时, 也可以写该成员.

1.5 decltype

decltype 得到变量或表达式的类型, 但是不会计算表达式. 注意:

int a, *b = &a, &c = a;
decltype(a) d;  // d的类型是int
decltype((a)) e = a;  //(a)是表达式,是一个lvalue,因此decltype会推导为引用类型,这样e一定要绑定某个值
decltype(*b) f = a; //同样,*b也是表达式,是lvalue
decltype(c) g = a;  //int&
decltype(a+0) h;  //int

以上代码可以这样理解:
首先把最外面的 decltype() 给抹掉:

decltype((i)) -> (i)
decltype(i) -> i

也就是说,第一个decltype是对应(i),第二个处理i。
在C++表达式中,第一个是"lvalue表达式";第二个是"一个变量"。
因为是lvalue,因此decltype会理解为引用。

1.6 static

static可以用于声明类静态成员, 表明类成员与对象无关。静态数据成员一般需要在类外初始化, 除了下面几种情况:

  • inline static: 可以在类内初始化
  • const static: 可以在类内初始化
  • constexpr static LiteralType: 必须在类内初始化(这个下面代码暂时没有,我也没懂)

总的来说:

  1. static可以用于函数中, 声明静态局部变量。变量在第一次调用该语句时被初始化, 以后经过该语句时不再初始化。并且从 C++11 开始, 这是线程安全的. 如果没有给定初始值, 会进行零初始化或调用默认构造函数.
  2. static用于声明静态全局变量, 表示该变量只在本文件内可见, 即使其他文件声明了该变量, 链接时也无法找到它。
  3. static用于声明静态函数, 表示该函数只在本文件内可见。
namespace sanjay1{
  class Test{
   public:
    inline static int a = 6;  //c++17才有
    // static int b = 7;  // 直接static是不能在类内初始化的
    const static int c = 8; // 这样也可以
  };
}

1.7 inline

  • inline只是建议编译器内联,并不能保证,是否内联由编译器决定;
  • inline是在编译期确定的,因此对于通过引用或指针调用的虚函数,不会发生内联,因为这个在运行的时候才知道调用哪个函数。

1.8 this

  • 在类的非静态函数中,隐含了this指针,隐式声明为ClassName* const this,因此不能对this指针赋值;
  • this是一个右值,因此不能取this地址;
  • 当对象是常量, 或通过常引用调用函数, 或函数被声明为 const 时, this 指针被隐式声明为 const ClassName * const this,因此不能修改对象成员, 对于前两者, 不能调用对象的非 const 函数。示例代码如下:
namespace sanjay2{
  class Test{
   public:
    void func(){
      cout << "non-const func" << endl;
    }
    void func() const {
      cout << "const func" << endl;
    }
    void another(){
      cout << "another" << endl;
    }
  };
  void test(){
    Test t;
    t.func(); //带const和不带const的可以重载
    const Test t2;
    t2.func();
    // t2.another(); //error
  }
}

1.9 pragma pack

其作用是改变默认的最大对齐方式,针对的是随后的 struct, union, class 的成员的对齐. 要求指定的对齐大小必须是2的幂。

// 设置新的对齐方式
#pragma pack(n)

1.10 extern

首先介绍一下链接, 是指将不同的编译单元组合为一个可执行文件的过程. 其中涉及的一个问题是一个编译单元引用了其他编译单元定义的函数,
在链接过程中就需要将这个引用修改为正确的函数地址, 因此就需要保证链接器能找到引用的函数.
这就需要两个编译单元使用同样的 calling convention, name mangling algorithm 等.
对于 name mangling, C++由于支持重载, name mangling 还包含了函数的参数.
而C语言不支持重载, name mangling 只使用了函数名.

因此C++和C语言混合使用时,需要使用extern "C"声明,保证能互相调用。具体包括下面的几种情况:

  • 用于C++代码中,修饰C函数声明,函数定义在C编译单元中。这使得C++编译单元可以和C编译单元链接,即在C++中可以调用C编译单元中定义的代码。例如:
// C++ source code
extern "C"
{
    int open(const char *pathname, int flags); // C function declaration
}
int main()
{
    int fd = open("test.txt", 0); // calls a C function from a C++ program
}
  • 用于C++代码中,修饰函数定义。这使得在其他C编译单元中可以调用该函数,在函数定义中可以使用C++语法、标准库等。例如:
// C++ source code
extern "C" void handler(int) // can be called from C source code
{
    std::cout<<"Callback invoked\n";
}

但是注意,当块中出现类成员声明和类函数声明时,即使声明了 extern “C”,仍然会被作为 extern “C++”。

1.11 强制类型转换

  • static_cast: 非多态类型的转换, 可以将子类(引用/指针)转换为父类(引用/指针), 不能向下转换。
  • const_cast: 用于改变对象的 const/volatile. 如果对象本身是一个常量, 移除 const 属性后对其修改是合法的。
namespace sanjay3
{
  void test_const() {
    const int a = 20;
    int* b = const_cast<int*>(&a);
    *b = 30;
    cout << *b << endl;
  }
} // namespace sanjay3
  • reinterpret_cast: 为运算对象的位模式提供较低层次上的重新解释, 常用于改变指针的类型。尽量不要使用 reinterpret_cast, 无关类型的转换是不安全的.

  • dynamic_cast: 用于执行多态类型的转换, 只能用于指针或引用, 会进行运行时检查, 转换指针失败时返回 nullptr,
    转换引用失败时抛出 std::bad_cast 异常. 这是他区别于 static_cast 的地方.

  • C风格的强制类型转换: 当替换为 static_cast/const_cast 也合法时, 就是等价的, 不合法时,等价于 reinterpret_cast。

总的来说:
static_cast 的能执行的转换并不多, 例如不能将 char* 转换为 string*, 这需要 reinterpret_cast;
static_cast 一般要求类型之间有一定联系, 例如都是数值类型, 指针类型存在父子关系等;
reinterpret_cast 在执行指针转换时没有这个限制, 可以执行任意指针类型间的转换。

1.12 虚函数

参考《深度探索c++对象模型》书籍

2. 其他知识

2.1 引用折叠原则

有关右值引用参考本人博客
关于引用折叠原则,用typedef可以了解:

namespace sanjay4
{
  typedef int& lref;
  typedef int&& rref;
  void test() {
    int n = 10;
    lref& r1 = n; // type of r1 is int&
    lref&& r2 = n;  // int&
    rref& r3 = n; // int&
    rref&& r4 = 20;  // int&&
  }

} // namespace sanjay4

即有:

  1. T& & 被折叠为T&
  2. T&& &被折叠为T&
  3. T& &&被折叠为T&
  4. T&& &&被折叠为T&&

2.2 std::move和std::forward的实现

在理解源代码之前需要先知道的先验知识为

  1. 2.1的引用折叠
  2. std::remove_reference为C++0x标准库中的元函数,其功能为去除类型中的引用。如下面示例,其中等价于的意思:
std::remove_reference<U&>::type ≡ U
std::remove_reference<U&&>::type ≡ U
std::remove_reference<U>::type ≡ U

然后我们看看并分析一下move和forward源码:

template <class _Tp>
inline 
typename remove_reference<_Tp>::type&&
move(_Tp&& __t) _NOEXCEPT
{
    typedef typename remove_reference<_Tp>::type _Up;
    return static_cast<_Up&&>(__t);
}

template <class _Tp>
inline 
_Tp&&
forward(typename remove_reference<_Tp>::type& __t) _NOEXCEPT
{
    return static_cast<_Tp&&>(__t);
}

template <class _Tp>
inline 
_Tp&&
forward(typename remove_reference<_Tp>::type&& __t) _NOEXCEPT
{
    static_assert(!is_lvalue_reference<_Tp>::value,
                  "can not forward an rvalue as an lvalue");
    return static_cast<_Tp&&>(__t);
}

首先看move,不用管传入参数是左值还是右值,因为在函数中typedef typename remove_reference<_Tp>::type _Up;定义了_Up,这个会把传入的左值、左值引用和右值给去掉引用,只剩一个类型;然后返回的是static_cast<_Up&&>(__t);,这采用的是静态转换,把__t转换为右值引用(因为_Up这里是左值,没有引用叠加了),所以move函数完成的是把传入的类型转为右值。

同理看 forward的两个函数传入的参数分别为:
typename remove_reference<_Tp>::type& __t
typename remove_reference<_Tp>::type&& __t,说白了就是一个传入的是左值引用(T&),一个传入的是右值引用(T&&);然后两者返回的代码都一样:return static_cast<_Tp&&>(__t);,根据引用叠加原则,第一个返回的当然是左值引用,第二个返回的是右值引用。其实就是函数重载嘛。

以上理解,参考了如下链接:

  1. C++11编程知识:右值引用
  2. C++11尝鲜:std::move和std::forward源码分析
  3. 我自己的代码

2.3 线程相关(不懂)

thread 类用于创建一个线程, 对象一建立线程就开始运行. 传递给构造函数的是要运行的函数和传递给函数的参数,
一个要注意的问题是如果函数形参是引用, 需要用 std::ref(var) 来传递引用.
mutex 类表示互斥锁, shared_mutex 表示共享锁.
std::lock, std::try_lock 函数用于两个或两个以上的锁. 后者可以避免死锁. 当发生异常时, 二者会保证释放已lock的锁.
lock_guard 模板类是 RAII 的代表, 使用时用一个锁变量(如mutex)来构造它, 析构时析构函数为自动释放锁,避免了忘记释放锁导致的问题. 如下所示:

std::mutex m;
void f()
{
    std::lock_guard<std::mutex> lg(m); // call m.lock() in constructor.
    // do some job;
    // ...
} // call m.unlock() in lock_guard's destructor

unique_lock 实现了对锁的 RAII, 并且允许将锁的拥有权转移到其他 unique_lock 变量. 并且允许更加精细的对锁的操作, 如下:

std::mutex m1, m2;
void f0()
{
    // 在构造函数中用 lock 获取锁, 等价于 lock_guard.
    std::unique_lock<std::mutex> ul1(m1);
    std::unique_lock<std::mutex> ul2(m2);
} // 析构函数释放锁.
void f1()
{
    // defer_lock 表示不获取锁, 由后面的其他语句获取锁.
    std::unique_lock<std::mutex> ul1(m1, std::defer_lock);
    std::unique_lock<std::mutex> ul2(m2, std::defer_lock);
    std::lock(m1, m2);
} // 析构函数释放锁.
void f2()
{
    // try_to_lock 表示在构造函数中用 try_to_lock 获取锁.
    std::unique_lock<std::mutex> ul1(m1, std::try_to_lock);
    std::unique_lock<std::mutex> ul2(m2, std::try_to_lock);
    std::lock(m1, m2);
} // 析构函数释放锁.
void f1()
{
    std::lock(m1, m2);
    // adopt_lock 表示假定线程已经获得了锁, 不再在构造函数中获取锁.
    std::unique_lock<std::mutex> ul1(m1, std::adopt_lock);
    std::unique_lock<std::mutex> ul2(m2, std::adopt_lock);
} // 析构函数释放锁.

call_once 函数结合 once_flag 实现保证可调用对象只会被调用一次. 如下:

std::once_flag flag;
 
void f(int i1, int i2);
 
std::call_once(flag, f, i1, i2);

condition_variable 表示条件变量类. 使用方式如下:

std::mutex m;
std::condition_variable cv;
 
void reader()
{
    m.lock();
    cv.wait(lk, callable); // 表示当调用 callable() 为 true 时才会返回.
 
    // process ...
 
    m.unlock();
}
 
void writer()
{
    m.lock();
 
    // process ...
 
    m.unlock();
    cv.notify_one();
}

promise 类用于线程之间的通信, 创建者将该类型的变量传递给线程函数, 当线程设置该变量的值时, 会通知创建者, 从而获取值.
其中还需要结合 future 类. 如下所示, 还可以将promise保存的对象类型设为 void 来实现线程和创建者间的同步.

void worker(std::promise<std::string> work_promise)
{
    // process ...
    work_promise.set_value(str);
}
 
std::promise<std::string> work_promise;
std::thread t(worker, work_promise);
std::future<string> work_future = work_promise.get_future();
 
work_future.wait(); // blocked until worker call set_value.
work_future.get();  // return value set by worker.

原子操作. 用 atomic 类包装数据, 支持整型, 指针, bool类型.

3. 问题

3.1 复制构造函数形参类型问题

  • 复制构造函数参数为什么不能是值, 必须是引用? 可以是非const, 那么为什么一般定义成const?
    必须为引用的原因:
    当参数定义为值类型时, 将参数传递到复制构造函数中需要一次复制, 又会调用复制构造函数,这样会导致无限地递归调用. 因此C++规定复制构造函数参数必须是引用。

    定义成const引用有下面几个原因:
    1.复制一个对象时, 按照语义不应该修改原对象. 即使做一些计数类的操作, 也应该将这些成员声明为mutable
    2.当原对象是const类型时, 如果不将参数定义为const, 就无法复制该对象。比如:原对象是const T类型,如果复制构造函数参数不是const的话,编译器无法将const类型的转为non-const
    3.如果不将参数定义为const, 就无法复制一个临时对象. 临时对象是一个右值, 非常量引用无法绑定到右值。这里的例子如:创建一个临时变量,是一个右值,想调用拷贝构造函数,同时传入这个临时变量,这个时候如果是non-const,是接不了右值的,只有const才能接住。这就好比int& i = 32是error的,但是const int& j = 34;是可行的一个道理。

3.2 单例模式实现

  • 懒汉模式(lazy singleton):单例实例在第一次使用的时候才进行初始化,成为延迟初始化。
class LazySingleton {
private:
    LazySingleton() {
        // ...
    }
public:
    // 拷贝构造函数需要删除
    LazySingleton(const LazySingleton&) = delete;
    // 赋值构造函数也是
    LazySingleton& operator=(const LazySingleton&) = delete;
    static LazySingleton& GetInstance() {
        static LazySingleton lazy_singleton;	//c++11可以保证线程安全
        return lazy_singleton;
    }
};

// 使用
auto& lazy_singleton = LazySingleton::GetInstance();
  • 饿汉模式(eager singleton):在使用实例前就进行了初始化。
class EagerSingleton {
private:
    EagerSingleton() {
        // do something...
    }
    static EagerSingleton eager_singleton_ ;  //类内成员是需要在类外初始化的,静态成员
    inline static int a = 2;    //使用inline是可以在类内初始化的
public:
    EagerSingleton(const EagerSingleton&) = delete;
    EagerSingleton& operator=(const EagerSingleton&) = delete;
    static EagerSingleton& GetInstance() {
        return eager_singleton_;
    }
};

EagerSingleton EagerSingleton::eager_singleton_;

// 使用
auto& eager_singleton = EagerSingleton::GetInstance();

3.3 为什么不能根据返回值类型进行函数重载?

因为在调用函数时无法显式地提供函数返回值类型, 这就导致编译器不能进行重载决议。即使有时候会将函数返回值赋予某些变量,但是有时候程序员也不会保存函数的返回值, 仅仅是直接调用函数。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zedjay_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值