《剑指Offer》第2章笔记 编程语言

第2章主要围绕编程语言数据结构算法,介绍技术面所需要的“基础知识”。

编程语言

书里代码都是用 C/C++/C# 实现的,后面分别从 C++C# 语言的角度来讲述其中一些被问道的细节点,这样的细节点因为篇幅限制,不可能都写进书里,所以还需要通过阅读对应编程语言的书籍来进阶了解,同时其他语言的使用者也可以从其中窥探到面试官在针对语言掌握的考量时,都是从什么样的角度出发的。

C++

通常语言面试的问题有3种类型:

  1. 对于语言中概念的理解程度;
  2. 面对代码,分析运行结果(或错误);
  3. 在上下文环境中,定义类型或实现函数。

作者推荐的几本C++书,根据自己的情况选择阅读顺序:

  • 《Effective C++》:书中列举了C++经常出现的问题以及解决这些问题的技巧(大多是面试官比较喜欢问的方向),适合面试之前突击C++;
  • 《C++ Primer》:人称C++全书,适合全面了解的时候阅读,也可以当作宝典查询;
  • 《深度探索C++对象模型》:深度了解C++对象的内部机制,介绍很多较为底层的知识点;
  • 《The C++ Programming Language》:C++圣经(大概),适合全面深入掌握C++。

下面通过一道面试题(第3种类型)来表现一下这类题目是如何考查语言知识点的。

面试题1:赋值运算符函数,如下为类型CMyString的声明,请为该类型添加赋值运算符函数。

1
2
3
4
5
6
7
8
9
class CMyString {
public:
CMyString(char* pData=nullptr);
CMyString(const CMyString& str);
~CMyString(void);

private:
char* m_pData;
};

Tips:赋值运算符主要负责变量在进行赋值 = 运算时,如何处理变量对象内部成员变化。

定义C++中的赋值运算符函数时,需要关注点有:

  • 返回值类型声明为引用,函数结束前返回实例自身的引用,因为返回引用才允许连续赋值的情况,例如 str1 = str2 = str3,否则无法通过编译;
  • 传入的参数类型声明为常量引用,如果不是引用,形参到实参传递会调用一次复制构造函数(函数的传值引用),引用可以避免这样的开销;同时因为赋值运算并不会修改传入的实例的状态,所以应进一步加上 const 关键字;
  • 释放实例自身已有的内存,主要在对象有动态分配内存情况下考虑,如果忘记在分配新内存前释放旧内存空间,程序旧出现了内存泄漏(memory leakge),这块内存无法被回收使用(资源浪费、占用空间导致空间不够用);
  • 判断传入参数和当前实例(*this)是不是同一个实例,是则不进行赋值,如果不判断就直接赋值,在同一个实例情况下,会导致在还没有拷贝到传入参数(自己)的内存时,就释放自己的内存,这也就导致传入参数的内存被释放了(因为函数是传引用),最后找不到待赋值的内容。

经典解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <cstring>

CMyString& CMyString::operator=(const CMyString& str) {
if(this == &str) { // 对象的 this 是一个地址
return *this; // 传对象 而不是地址
}

delete []m_pData; // 数组的释放方式
m_pData = nullptr;

m_pData = new char[strlen(str.m_pData) + 1]; // 申请新空间,多的1位给'\0'
strcpy(m_pData, str.m_pData); // 复制 赋值

return *this;
}

如果对自己有更高的要求,应该再进一步地考虑其中涉及到的问题。

前面的函数中,分配内存之前先释放了内存,如果在分配内存时,内存不足就会导致 new char 排除异常,m_pData将是一个空指针,并且无法回退到之前的结果,也就是说一旦赋值运算符内部抛出了异常,实例不再保持有效的状态(并不是原有的状态),这违背了异常安全性(Exception Safety)原则(正常情况下,如果抛出异常也不应该导致内容被错误操作)。

  • 简单的方法是先用 new 分配新内容,再 delete 释放已有的;
  • 更好的办法是创建一个临时实例,再交换临时实例和原来的实例。
1
2
3
4
5
6
7
8
9
10
#include <cstring>
#include <algorithm>

CMyString& CMyString::operator=(const CMyString& str) {
if(this == &str) {
CMyString tmp(str); // 复制构造函数
std::swap(m_pData, tmp.m_pData); // 交换地址
}
return *this;
}

tmp 局部变量遇到 if 结束时,会自动调用它的析构函数,会把交换下来的 thism_pData 所指向的内存释放掉;在新的代码中,在复制构造函数中分配内存,如果由于内存不足抛出异常时,由于此时还没有修改原来的实例状态,实例的状态依旧是有效的(原有的),也就保证了异常安全性。

完整代码(包含所有实现和测试):

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include <iostream>
#include <cstring>
#include <algorithm>

class CMyString {
public:
CMyString(char* pData); // 第一个参数设定默认值会导致无法通过编译
CMyString(const CMyString& str);
~CMyString(void);
CMyString& operator=(const CMyString& str); // 赋值运算符函数声明
void print(void); // 标准输出函数

private:
char* m_pData;
};

// 构造函数
CMyString::CMyString(char* pData) {
if(pData != nullptr) {
m_pData = new char[strlen(pData) + 1];
strcpy(m_pData, pData);
}
}

// 复制构造函数
CMyString::CMyString(const CMyString& str) {
if(str.m_pData != nullptr) {
m_pData = new char[strlen(str.m_pData) + 1];
strcpy(m_pData, str.m_pData);
}
}

// 析构函数
CMyString::~CMyString(void) {
if(m_pData != nullptr) {
delete []m_pData;
m_pData = nullptr;
}
}

void CMyString::print(void) {
std::cout << m_pData << std::endl;
}

// 赋值运算符函数
CMyString& CMyString::operator=(const CMyString& str) {
if(this != &str) {
CMyString tmp(str); // 复制构造函数
std::swap(m_pData, tmp.m_pData); // 交换地址
}
return *this;
}

int main() {
// 测试CMyString
CMyString str1("Sword");
CMyString str2("2");
CMyString str3("Offer");
str1 = str2 = str3;
str1.print(); // Offer
str2.print(); // Offer
str3.print(); // Offer
return 0;
}

如果完整的去实现上面这个题目,会引出一些非常容易忽略的细节问题(并且会导致严重错误),例如在初始化变量 str1 时,CMyString str1("Sword");,其中所传入的参数一般都是程序声明的字符串常量,如果在构造函数中简单地实现为:

1
2
3
4
// Mem Error
CMyString::CMyString(char* pData) {
m_pData = pData;
}

就会导致赋值运算符函数中的析构函数调用过程发生错误,因为析构函数中的 delete []m_pData 的delete操作符只负责操作堆(Heap)中的内存区域(因为new只在堆里申请内存区域),如果使用delete操作符去释放一个字符串常量指针所指向的区域,就会发生错误(大概是访问越界)。

所以在初始化时应该使用和delete对应的new操作,在堆中申请内存,再把传入参数的内容复制到其中。

1
2
3
4
5
6
CMyString::CMyString(char* pData) {
if(pData != nullptr) {
m_pData = new char[strlen(pData) + 1];
strcpy(m_pData, pData);
}
}

C#

因为还没怎么学C#的东西,C#的部分暂时跳过,记录一下推荐的书:

  • 《Professional C#》:特点是附录中有描述C#和其他语言的区别;
  • 《CLR Via C#》:深入介绍C#,对CLR和.NET进行剖析,可以方便理解装箱卸箱、垃圾回收、反射等概念。

面试题2:实现Singleton模式(单例模式)

暂时跳过。

涉及到设计模式的部分,列举一些可以参考阅读的资料:

《剑指Offer》第2章笔记 编程语言

https://yumi-cn.github.io/2020/11/25/s2o-c2-lg/

作者

Yumiko

发布于

2020-11-25

更新于

2020-11-28

许可协议

评论