八股文C++

你知道c++三大特性吗?那你又是否清楚他们的特点呢?

c++三大特性分别为:封装、继承和多态

封装: 1、三个特点

结合性,即将属性(数据成员)和行为(成员函数)相结合

信息隐蔽性,利用接口机制隐蔽内部实现细节,只留下接口给外界调用

实现代码重用,在此举个例子解释所谓的实现代码重用:

例如,如果你需要在程序的多个地方进行文件读写操作,你可以创建一个名为FileHandler的类,将文件的打开、关闭、读取和写入等操作封装在该类中的成员函数中。然后,你可以在程序的各个地方实例化FileHandler对象,并调用其成员函数来执行文件操作,而不需要每次都编写打开、关闭、读取和写入的代码。

这种方式可以大大简化程序的编写和维护,并且当你需要对文件操作的逻辑进行修改时,只需要修改FileHandler类中的代码,而不需要修改所有调用该类的地方。

继承:

类的派生就是指从已有类产生新类的过程,原有类称为基类或父类,产生的新类称为子类或派生类,子类继承基类后,可以创建子类对象调用基类的函数和变量等。

多态:

多态(Polymorphism)是面向对象编程中的一个重要概念,它允许使用基类类型的指针或引用来调用派生类对象的特定方法。C++中的多态性是通过虚函数(virtual function)和动态绑定(dynamic binding)实现的。

在C++中,要实现多态,需要满足以下条件: 1. 基类(父类)中声明一个虚函数。 2. 派生类(子类)中重写(覆盖)这个虚函数,并使用关键字override进行标记。

例如,考虑一个基类Shape和两个派生类CircleRectangle的示例:

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
#include <iostream>

class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape." << std::endl;
}
};

class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle." << std::endl;
}
};

class Rectangle : public Shape {
public:
void draw() override {
std::cout << "Drawing a rectangle." << std::endl;
}
};

int main() {
Shape* shape1 = new Circle();
Shape* shape2 = new Rectangle();

shape1->draw(); // 调用派生类的方法:Drawing a circle.
shape2->draw(); // 调用派生类的方法:Drawing a rectangle.

delete shape1;
delete shape2;

return 0;
}

在上面的例子中,基类Shape声明了一个虚函数draw(),并且两个派生类CircleRectangle分别重写了这个函数。在main()函数中,我们创建了两个指向基类的指针shape1shape2,分别指向派生类CircleRectangle的对象。然后,通过基类指针调用draw()函数,由于这个函数是虚函数,实际调用的是派生类的版本,即产生了多态行为。

通过多态,我们可以使用统一的接口来处理不同的对象,提高了代码的灵活性和可维护性。此外,通过将基类指针或引用传递给函数或方法,我们可以在运行时确定要调用的具体实现,而不是在编译时静态地确定。

需要注意的是,在基类中使用关键字virtual来声明虚函数是很重要的,而派生类中的重写函数则使用关键字override进行标记,以确保正确的绑定和多态行为。

函数体内部声明一个string变量,未进行初始化,会进行默认初始化吗?

在c++中,如果你在函数体内部声明一个std::string变量而不进行初始化,该变量会被默认初始化。对于std::string,默认初始化意味着会创建一个空字符串。

例如:

1
2
3
4
void someFunction(){
std::string s;
// .....
}

在这个例子中,s 被默认初始化为一个空字符串。当你尝试打印 s 或者获取 s.length() 时,你会发现s是一个空字符串。

注意这与内置类型如intfloat等不同,这些类型如果没有明确初始化,它们的值是未定义的。

string属于内置类型吗?

是的,string 类型不属于内置类型。std::string 是 C++ 标准库中的一个类,它是对字符数组(C 风格字符串)的封装,提供了更加方便、安全的字符串操作功能。

内置类型(也称为基本类型或原始类型)是编程语言中预定义的、最基本的数据类型,例如 intfloatdoublechar 等。这些类型通常直接映射到计算机硬件上的表示,所以操作起来非常高效。

相比之下,std::string 是一个类,其对象由多个数据成员和成员函数组成,提供了对字符串的各种操作。虽然 std::string 不是内置类型,但它在 C++ 中被广泛使用,因为它提供了比 C 风格字符串更方便和安全的方式来处理字符串。

#pragma once的作用和#ifndef一样么

是的,#pragma once#ifndef(加上对应的 #define#endif)都用于防止头文件的重复包含。它们的作用相似,但实现方式有所不同。

  1. #pragma once#pragma once 是一种编译器指令,用于确保头文件只被包含一次。当编译器遇到 #pragma once 时,它会记录已经包含了该头文件,从而避免重复包含。这种方法简洁易懂,只需在头文件的开头添加一行代码。

    示例:

    1
    2
    3
    4
    // header.h
    #pragma once

    // ... 声明和定义

    请注意,#pragma once 不是 C++ 标准的一部分,但大多数现代编译器都支持它。

  2. #ifndef#define#endif: 这是一种被称为“包含保护”的技术,通过预处理器指令来防止头文件的重复包含。首先使用 #ifndef 检查一个特定的宏是否已定义,如果未定义,则使用 #define 定义宏,并将头文件的内容包含在 #ifndef#endif 之间。

    示例:

    1
    2
    3
    4
    5
    6
    7
    // header.h
    #ifndef HEADER_H
    #define HEADER_H

    // ... 声明和定义

    #endif // HEADER_H

    这种方法是 C++ 标准的一部分,因此具有更好的可移植性。

总的来说,#pragma once#ifndef 都可以防止头文件的重复包含,但实现方式不同。在实际项目中,你可以根据编译器支持和代码可移植性的需求来选择其中一种方法。

总结一下上面的:作用是一样的,关于ifndef的具体细节可以去看C++ primer第68页的有关介绍。

C++分离式编译

举例一个简单情况:

我在Chapter6.h头文件中声明了fact函数,在fact.cpp文件中定义了fact函数,并在factMain.cpp文件中使用了fact函数,为什么在factMain.cpp文件中,只需要引入Chapter6.h头文件就可以调用到fact函数,Chapter6.h头文件里不是只有一个声明吗?

解答:

Chapter6.h 头文件中只有 fact 函数的声明。然而,这就是在 C++ 中分离声明和定义的好处。你在头文件中声明函数(告诉编译器有一个名为 fact 的函数存在),然后在一个源文件(例如 fact.cpp)中定义该函数(提供函数的具体实现)。这样,其他源文件(例如 factMain.cpp)可以通过包含相应的头文件来获取函数的声明,从而能够调用该函数。

在编译阶段,编译器会分别编译 fact.cppfactMain.cpp,并生成相应的对象文件(例如 fact.ofactMain.o)。在 fact.cpp 中定义的 fact 函数的具体实现会在 fact.o 中。

然后在链接阶段,链接器会把所有的对象文件连接在一起,形成一个可执行文件。链接器会解决所有未解决的符号引用,例如在 factMain.o 中对 fact 函数的调用。因为 fact 函数在 fact.o 中定义了,所以链接器能够找到它,并正确地设置引用。

因此,尽管 Chapter6.h 头文件中只有 fact 函数的声明,但只要 fact 函数在其他地方被定义,那么在 factMain.cpp 中就可以通过包含 Chapter6.h 来调用 fact 函数。

为什么不可以同时用conststatic修饰成员函数。详细解释一下

C++中,conststatic都可以用于修饰成员函数,但它们有不同的含义,并且不能同时应用于同一个成员函数。下面我们来详细了解一下:

  1. const成员函数:如果一个成员函数被声明为const,那么它就不能修改对象的任何非静态数据成员或者调用任何非const成员函数。在const成员函数中,关键字const是在参数列表之后声明的。const成员函数可以被任何类型的对象调用,无论是const还是非const对象。

    例如:

    1
    2
    3
    4
    5
    class MyClass {
    public:
    void nonConstMethod() { /* 可以修改对象的状态 */ }
    void constMethod() const { /* 不能修改对象的状态 */ }
    };
  2. static 成员函数:static 成员函数与类的对象无关,不需要对象就可以调用。它不能访问类的非静态成员(包括数据成员和成员函数),因为非静态成员都是与特定对象关联的,而 static 成员函数与任何对象都无关。

    例如:

    1
    2
    3
    4
    5
    class MyClass {
    public:
    static void staticMethod() { /* 不能访问非静态成员 */ }
    void nonStaticMethod() { /* 可以访问非静态成员 */ }
    };

这就是为什么不能同时用 conststatic 修饰成员函数的原因:const 成员函数是基于对象的,它不能改变对象的状态,但可以访问对象的状态。而 static 成员函数是不基于对象的,它不能访问对象的状态。因此,conststatic 在这里是冲突的,不能同时用于同一个成员函数。

如果一个成员函数被声明为 const,那么它就不能修改对象的任何非静态数据成员或者调用任何非 const 成员函数。能举个例子吗?解释的更通俗一点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyClass {
int data;

public:
MyClass() : data(0) {}

void increment() { ++data; } // 非const成员函数

int getValue() const { return data; } // const成员函数

void tryToModify() const {
// ++data; // 这里将导致编译错误,因为不能在const成员函数中修改非静态数据成员
// increment(); // 这里也将导致编译错误,因为不能在const成员函数中调用非const成员函数
}
};

在这个类中,increment() 是一个非 const 成员函数,它可以修改对象的状态。getValue() 是一个 const 成员函数,它不能修改对象的状态,只能返回对象的状态。

tryToModify() 也是一个 const 成员函数,试图在其中修改 data 或者调用 increment() 都会导致编译错误,因为这些操作都会改变对象的状态,而 const 成员函数不允许这样做。

通俗来说,const 成员函数就像一个诺言,它承诺我不会改变对象的状态。这样,你就可以放心地把对象传给这个函数,知道这个函数不会做任何坏事。这在编程中是非常有用的,因为它可以帮助你避免一些常见的错误,并使你的代码更容易理解和维护。

static 成员函数与类的对象无关,不需要对象就可以调用。这句话怎么理解呢?不需要对象如何调用呢?直接传参吗?能举个例子吗

static 成员函数是与类本身相关联的,而不是与类的特定实例(即对象)相关联。因此,你不需要创建一个类的实例就可以调用 static 成员函数。你可以直接使用类名和作用域解析运算符 :: 来调用 static 成员函数。

下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
class MyClass {
public:
static void staticMethod() {
std::cout << "Calling static method" << std::endl;
}
};

int main() {
MyClass::staticMethod(); // 不需要创建MyClass的实例就可以调用staticMethod
return 0;
}

在这个例子中,我们没有创建 MyClass 的实例就直接调用了 staticMethod。这是因为 staticMethod 是一个 static 成员函数,它与类本身相关联,而不是与类的特定实例相关联。

注意,static 成员函数不能访问类的非 static 成员(包括数据成员和成员函数),因为非 static 成员都是与特定对象关联的,而 static 成员函数与任何对象都无关。这也是为什么 static 成员函数不能有 this 指针的原因。

深拷贝和浅拷贝

在用户未自定义拷贝构造函数的情况下,系统会调用默认的拷贝构造函数,系统默认的拷贝构造函数执行浅拷贝。当数据成员中没有指针时,浅拷贝是可行的。

但当数据成员中出现指针时,如果依旧使用默认的拷贝构造函数,就会出现调用两次析构函数,造成悬空指针问题。

深拷贝与浅拷贝的区别就在于深拷贝会在堆内存中另外申请空间来储存数据,从而也就解决了指针悬挂的问题。简而言之,当数据成员中有指针时,必须要用深拷贝。

问了一个很愚蠢的问题:我发现在定义一个类的时候,构造函数会对private里的类似于int,string和指针等类型显式或隐式的初始化,而一些函数只给出了声明,函数为什么不用给出定义?

C++中,类的定义通常分为两部分:声明和定义。声明是告诉编译器某个东西(例如变量、函数、类等)的存在,而定义则提供了这个东西的实际实现或分配了它的内存。

在类的定义中,构造函数对数据成员进行初始化,这是构造函数的主要目的。对于简单类型如intstring或者指针,可以在构造函数中显式初始化,也可以使用默认的初始化(对于类类型,调用其默认构造函数;对于内置类型,如果它们是静态存储期的对象,则进行值初始化,即初始化为0,否则不进行初始化)。

对于成员函数,只需要在类定义中给出声明,这是因为我们通常希望将函数的定义(实现)放在类定义之外,例如在一个.cpp文件中。这样可以将接口(即类的声明)与实现(即函数的定义)分离,使代码更清晰,易于理解和维护。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// MyClass.h
class MyClass {
public:
MyClass(int a); // 构造函数声明
void foo(); // 成员函数声明
private:
int data;
};

// MyClass.cpp
#include "MyClass.h"
#include <iostream>

MyClass::MyClass(int a) : data(a) {} // 构造函数定义

void MyClass::foo() { // 成员函数定义
std::cout << data << std::endl;
}

当然,也可以直接在类定义中提供成员函数的定义。这通常在函数体很短,或者希望函数被内联(inline)时这样做:

1
2
3
4
5
6
7
class MyClass {
public:
MyClass(int a) : data(a) {}
void foo() { std::cout << data << std::endl; }
private:
int data;
};

在这种情况下,foo函数会自动被编译器视为内联函数,可以减少函数调用开销,但可能增加代码大小。

数组不允许拷贝和赋值

不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值。

1
2
3
int a[] = {0, 1, 2};
int a2[] = a; //错误,不允许使用一个数组初始化另一个数组
a2 = a; //错误,不能把一个数组直接赋值给另外一个数组

标准库函数begin()和end()

数组毕竟不是类类型,因此这两个函数不是成员函数,正确的使用形式是把数组作为他们的参数传入:

1
2
3
int ia[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
int* beg = begin(ia); //指向ia首元素的指针
int* last = end(ia); //指向ia尾元素的下一位置的指针
创建一个vector容器存放string类型的对象

1、首先我们确定一下这个类要实现哪些功能,初步设计包括三个:

  • 实现push_back向容器尾后迭代器指定位置添加元素;
  • 实现size获取容器中存放元素个数的功能;
  • 实现capacity获取容器当前最大容量的功能;

同时,可以想到以上三个要在public里实现,作为对外的接口。

2、要实现以上这些功能(拷贝控制函数那些后面再实现)需要哪些变量呢?

  • 在一开始,我们需要获得一个原始内存,在这里我们采用一个allocator来获得原始内存;

  • 三个指针,分别指向vector容器的首元素、最后一个实际元素之后的位置、分配的内存末尾之后的位置;

3、拷贝控制函数的实现需要哪些工具函数

为了方便各种拷贝控制函数的实现,我们需要实现一些工具函数。拿拷贝构造函数举例来说,我们需要对一个vector容器对象进行拷贝,在拷贝构造函数里直接调用工具函数肯定会使得整个代码结构更加清晰明朗。

  • alloc_n_copy:分配内存,并拷贝一个给定范围中的元素;
  • free:销毁构造的元素并释放内存;
  • chk_n_alloc:保证StrVec至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc会调用reallocate来分配更多内存;
  • reallocate在内存用完时为StrVec分配新内存;
我决定先实现StrVec类的拷贝构造函数

我们可以将拷贝构造函数设想为将一个vector容器里的所有string拷贝到另一个vector里面。可以简单思考一下,在实现拷贝构造函数时最先遇到的两个问题:

  • 既然我要拷贝到另外一个vector里面,那我要如何分配这个新vector的空间?
  • 分配的新空间应该多大呢?

在分配空间时我们需要使用到c++里的模板类allocator(后面我会给出有关介绍),

返回类型+函数名+形参列表+const+函数体,请问这个const作用是什么

C++中,函数后面的 const 关键字用于说明函数是一个常量成员函数。这意味着该函数不能修改它所在类的任何非静态成员变量的值。换句话说,常量成员函数不能修改对象的状态。

例如,考虑以下类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyClass {
private:
int x;

public:
MyClass(int val) : x(val) {}

int getValue() const {
return x;
}

void setValue(int val) {
x = val;
}
};

在这个例子中,getValue() 是一个常量成员函数,因为它后面有 const 关键字。这意味着 getValue() 函数不能修改 MyClass 对象的任何成员变量的值(在这个例子中是 x)。如果你尝试在 getValue() 函数中修改 x 的值,编译器会报错。

常量成员函数通常用于定义不应改变对象状态的操作。这有助于提高代码的安全性和易读性,因为读者可以明确知道哪些函数不会修改对象的状态。

另外,常量成员函数可以在常量对象上调用,非常量成员函数则不能。这使得你可以在需要保证对象不被修改的情况下,仍然可以调用其上的一些成员函数。

常量成员函数可以在常量对象上调用,非常量成员函数则不能。这句话的意思是常量对象不能调用非常量成员函数?

常量对象(即被 const 修饰的对象)只能调用常量成员函数。这是因为常量对象一旦初始化,其值就不能再被改变。因此,为了保证常量对象的状态不被改变,编译器禁止在常量对象上调用可能会改变对象状态的非常量成员函数。

举一个例子,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyClass {
public:
int x;

MyClass(int val) : x(val) {}

int getValue() const { return x; } // 常量成员函数
void setValue(int val) { x = val; } // 非常量成员函数
};

int main() {
const MyClass obj(10); // 声明一个常量对象
std::cout << obj.getValue() << std::endl; // 合法,因为getValue是常量成员函数
obj.setValue(20); // 非法,编译错误。因为setValue是非常量成员函数,不能在常量对象上调用
return 0;
}

在上述代码中,obj 是一个常量对象。尽管 setValue()MyClass 的一个成员函数,但你不能在 obj 上调用它,因为 setValue() 可能会改变对象的状态,这违反了 obj 是常量的事实。但你可以在 obj 上调用 getValue(),因为它是一个常量成员函数,不会改变对象的状态。

总结:常量对象不能调用非常量成员函数,非常量对象可以调用常量成员函数。(c++ primer p231:引入const成员函数)

封装定义

封装是指保护类的成员不被随意访问的能力,通过把类的实现细节设置为private,我们就能完成类的封装,实现类的接口与实现分离。

封装的两个重要优点:

1、用户代码不会无意间破坏封装对象的状态;

2、被封装的类的具体实现细节可以随时改变,而无需调整用户级别的代码;

友元

类允许其他类或者函数访问它的非公有成员(私有成员),方法是将其他类或者函数定义成友元,使用关键字friend

注意:

友元只能出现在类定义的内部,在类内出现的具体位置不受约束。友元不是类的成员,不受它所在区域访问控制级别的约束。

右值引用

右值引用主要用来解决两个问题:

1、临时对象非必要的昂贵的拷贝操作

2、在模板函数中如何按照参数的实际类型进行转发

c++11中所有的值必属于左值、将亡值和纯右值之一。

右值引用绑定了右值,让临时右值的生命周期延长了,可以利用这个特点做一些性能优化,即避免临时对象的拷贝构造和析构

右值引用类型既可能是左值也可能是右值,如T&& t,这里的t既可能是左值也可能是右值。如下所示

1
2
3
4
5
6
7
template<typename T>
void f(T&& t){}

f(10); //t是右值

int x = 10;
f(x); //t是左值

贴一个超级详细的连接