C++如何成了今天这副模样及其语法从编译器角度的合理性理解

以下内容转载自 https://www.toutiao.com/i6918277762868494862/

1 C++如何成了现在这个样子

C++最初发布于1980年代中期,当时面向对象语言被认为是解决软件复杂性问题的有力武器。C++的面向对象特性看上去使其全面超越了C,支持者认为C++将迅速把上一代语言挤到陈列馆里去了。

但是历史并非如此。究其原因,至少有一部分归咎于C++本身。为了与C兼容,C++被迫作出了很多重大的设计妥协,结果导致语言过分华丽,过分复杂。为了与C兼容,C++并没有采用自动内存管理的策略,双刃剑的效果就是保持了效率作为首要考虑因素的选择,但增加了代码量及出错的可能。

另外一部分原因,恐怕要算到面向对象身上。看起来OO并没有很好地达成人们当年的预期。OO方法导致组件之间出现很厚的粘合层,并且带来了严重的可维护性问题。今天让我们来看看开放源码社区,你会发现C++的应用还是集中在GUI、游戏和多媒体工具包这些方面,在其他地方很少用到。要知道,面向对象也只是在这些领域被证明非常成功,而开放源码社区的选择,很大程度上体现了程序员的自由意志,而不是公司管理层的胡乱指挥。

也许C++实现OO的方法有问题。有证据表明C++程序在整个生命周期的开销高于相应的C、Fortran和Ada程序。不过,究竟这是否应该归咎于C++的OO实现上,还不清楚。

最近几年,C++加入了很多非OO的思想,其异常思想类似Lisp,STL的出现就非常了不起。

其实C++最根本的问题在于,它基本上只不过是另一种传统的语言。STL中的内存管理比先前的new/delete和C的方案要好得多,但是还是没有解决问题。对于很多应用程序而言,其OO特性并不明显,相比于C,获得了一些改善的同时也增加了诸多的复杂性。

C++优点在于作为编译型语言,把效率、泛型与面向对象特性结合起来,其缺点在于过于华丽复杂。

Bjarne Stroustrup在《The design and evolution of C++》中谈到当时设计C++的初衷时说,效率和用类来组织代码的方式是首位考量的因素。而当时C的效率很好,且已流行起来,所以C++选择了无条件兼容C。另外,Bjarne Stroustrup有使用Simula的经验,对其用类来组织代码的方式十分赞赏。

2 为什么需要一个默认的构造函数?

需要用构造函数来创建对象,如果程序没有定义,编译器自动生成一个。

3 为什么一个空类声明的对象也有占用内存空间?

当我们声明该类型的实例的时候,它必须在内存中占有一定的空间才有可能有地址可以被访问,否则无法使用这些实例(一个类有时无数据成员,但可以定义成员函数,如函数对象,这样在访问时才可以通过对象名去引用成员函数)。至于占用多少内存,由编译器决定。Visual Studio 2008中每个空类型的实例占用1个byte的空间。

4 析构函数为什么只能有一个?

因为其不接收任何参数,因为接收参数也没有意义。不能接受参数则不存在重载,所以只能有一个。

5 为什么说Lambda表达式是函数对象的语法糖?

Lambda表达式是从类创建函数的精简方式。这里讲的类,它仅有的成员就是函数调用运算符。Lambda表达式取消了类声明,并且使用了精简的符号来表示函数调用运算符的逻辑。

class LessThan
{
public:
    bool operator()(int a, int b)
    {
        return a<b;
    }
}
// 同样的功能,Lambda表达式更简洁,更匿名
[](auto a, auto b){ return a<b;}

6 类型转换函数为什么不需要声明返回类型?

一个构造函数接收一个不同于基类类型的形参,可以视为将其形参转换成类的一个对象。像这样的构造函数称为转换构造函数。类型转换函数为什么不需要声明返回类型?因为从其函数名即可推断出函数返回类型。

7 为什么说涉及到继承的对象最好是动态分配到堆空间?

无论何时,只要涉及到继承,则所有对象都应动态分配并通过指针访问。分配在栈区的派生类对象赋值给基类对象时,基类对象的大小已固定,会分生切割。而动态分配的基类对象指针指向派生类对象,则不存在此情况。

8 为什么二进制文件用文本文件打开时显示为乱码?

文本文件包含已编码为文本的数据,因此可以在文本编辑器中打开和查看。二进制文件包含的是尚未编码为文本的数据,因此不可以在文本编辑器中查看。

9 基类构造函数的实参为什么不是在声明中指定,而是在定义中指定

基类构造函数的实参是在派生类构造函数的定义中指定的,而不是在声明中指定的,这是因为,声明是用于类型检查的,而定义则用于代码生成。

10 为什么要在派生类构造函数之前调用基类构造函数?

在派生类构造函数之前调用基类构造函数,这样,派生类构造函数可以使用已经初始化的基类对象的成员。将实参传递给基类的构造函数解决了继承中选择基类构造函数的问题。同样,组合(combination)也有该问题存在并且使用了相同的语法来解决。

11 关于类型匹配

catch块的形参匹配throw的相同数据类型。

根据数据类型执行不同代码的能力被称为多态。

12 为什么拷贝构造函数的形参必须是一个引用?

如果某个对象被通过值传递给拷贝构造函数,则不得不在传递给拷贝构造函数之前创建该参数的副本。但是,副本的创建又需要使用原始参数通过值传递来调用拷贝构造函数,从而导致对构造函数调用的无休止链条(循环调用)。

#include <iostream>
 
class A
{
private:
    int value;
 
public:
    A(int n)
    {
        value = n;
    }
 
    A(A other)  // 实参传值给形参,对于类对象,如果是值传递,需要调用拷贝构造函数。
    {
        value = other.value;
    }
 
    void Print()
    {
        std::cout << value << std::endl;
    }
};
 
int _tmain(int argc, _TCHAR* argv[])
{
    A a = 10;
    A b = a;
    b.Print();
 
    return 0;
}

13 为什么要使用智能指针?

因为效率的考量,以及与C的兼容,没有采用垃圾回收这种语法机制来管理堆内存,所以只有将视祼指针封装成智能指针,正如C++对普通数组不做边界检查,而在STL中封装了array、vector一样。智能指针的作用是管理一个指向堆内存的指针,因为存在以下这种情况:申请的空间在函数结束时忘记释放(或者因为异常,导致释放内存的代码得不到执行),造成内存泄漏。使用智能指针可以很大程度上地避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,不需要手动释放内存空间。除此之外,当多个指针指向同一块内存时,智能指针还可以处理其所有权的问题(如在构造函数中处理所有权转换或在析构函数中根据引用计数考虑是否析构)。

14 对于包含有指向动态内存指针的成员的类为什么要重写有默认版本的拷贝构造函数、赋值运算符重载,析构函数?

为了避免不同对象的成员指针指向同一个动态内存块,一个对象释放后,另一个对象无法访问。以及多次delete产生的问题。

15 可以深拷贝,为什么还要引入右值引用的语法机制?

右值引用是C++11 中引入的新特性, 它实现了转移语义和精确传递。它的主要目的有两个方面:I 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。II 能够更简洁明确地定义泛型函数。由此产生的移动构造和移动拷贝可以避免对于临时对象拷贝的效率损失,转移一下所有权和数据来得快捷,就像文件的move一样。

C++之父Bjarne Stroustrup在《The design and evolution of C++》一书中详细阐述了C++之所以是现在这个样子的历史由来。

16 尽量减少关键字,如类定义的构造与析构就没有引入关键字

构造函数没有采取以下的写法:

class x {
    constructor();
    destructor();
    // …
}

而是选择了能更好地反映构造函数使用形式的声明方式:

class x {
    x();  // constructor();
    ~x();  // destructor();
    // …
}

17 关于const常量

“在操作系统中,常常能见到人们用两个二进制位直接或间接地对一块存储区进行访问控制,其中用一个位指明某个用户能否在这里写,另一个指明该用户能否从这里读。我觉得这种思想可以直接用到C++中,因此也考虑过允许把一个类型描述为readonly或者writeonly。”

“直到现在,在C语言中还不能规定一个数据元素是只读的,也就是说,它的值必须保持不变。也没有任何办法去限制函数对传给它的参数的使用方式…readonly运算符可用于防止对某些位置的更新。它说明,对于所有访问这个位置的合法手段而言,只有那些不改变存储在这里的值的手段才是真正合法的。”

“我在离开这次会议时得到的是同意(通过投票)把readonly引进C语言(而不是C with Classes 或者C++),但把它另外命名为了const。”

“const来表示常数,可以成为宏的一种有用替代物,但要求全局const隐含地只在它所在的编译单元起作用。因为只有在这种情况下,编译器才能很容易推导出这些东西的值确实没有改变…把简单的const用到常量表达式中,并可以避免为这些常量分配空间。”

常量表达式中的值不必为其分配内存空间,与程序代码存储在一起。编译器通常不为普通const只读变量分配存储空间,而是将它们保存在符号表中,这使它成为一个编译期间的值,没有了存储与读内存的操作,使得它的效率也更高。

特殊情况下也会分配内存,由此编译器确定其为链接性。

“因为C语言中的const不能用在常量表达式中,这就使const在C语言中远没有在C++中那样有用。”

const引入的初衷是用来替换C的#define值替换的,#define一般放到头文件中,const(如果不是extern声明的话要同时初始化),而头文件中的内容是用来声明的,不能涉及到内存分配。所以const被C++编译器处理得很怪异,一方面是不分配内存,而将其保存到符号表,另一方面,如果万一涉及到内存分配,将其默认为内部链接性。

更详细的内容请参考C++|怪异的const及其不可变性、可修改性、连接性

18 类模板的一些细节

与类的声明相比,声明一个类模板并不复杂多少。关键字class用于指明类型参数的类型部分,一是因为它以很清楚的词的形式出现;二是因为这样可以节约一个关键字。在这个上下文环境里,class的意思是‘任意类型’,而不仅是‘某种用户定义类型’。

在这里使用尖括号<…>而不使用圆括号(…),是为了强调模板参数具有不同的性质(它们将在编译时求值),也因为圆括号在C++里已经过度使用了。

引进关键字template使模板声明很容易看清楚,无论是对人,还是对工具,同时也为模板类和模板函数提供了一种共有的语法形式。

模板是为生成类型提供的一种机制。它们本身并不是类型,也没有运行时的表示形式,因此它们对于对象的布局没有任何影响。”

“除了类型参数之外,C++也允许非类型的模板参数。这种机制基本上被看做是为容器类提供大小和限界所需的信息。例如:

template<class T, int i>
class Buffer{
    T v[i];
    int sz;
public:
    Buffer():sz(i) {}
    //...
;

在那些运行时间、效率和紧凑性非常紧要的地方,为了能与C语言的数组和结构竞争,这样的模板就非常重要了。传递大小信息允许实现者不使用自由空间。”

19 关于异常处理

“曾经试图用catch表示抛出和捕捉两种操作,这完全可以做得符合逻辑,也具有内在的一致性,但我在给人们解释这种模式时却没有成功。选择关键字throw,部分原因是比它更鲜明的词,例如raise和signal,都已经被C语言的标准库函数使用了。”

20 为用户提供的类型提供和内部类型同样好的支持(值语义)

有人认为所有类对象都应该放到堆区就是不可以接受的。因为像复数complex这种类,不仅仅应该和int这种基础类型一样默认放到栈区。而且所有对象放到堆区那就意味着C++要彻底大改造成带有垃圾回收的语言,这种剧烈的变化严重违反了C++准则(1.2 C++必须现在就是有用的),是不可接受的。这样以来,C++就应该和C一样是值语义的。

值语义的语言要解决String 这种成员有指针的情形:

String a,b; a = b;

这种情况无法进行成功的复制,因为复制就意这共享(两个对象的底层指针指向同一处)那么就要有复制控制,所以C++提供了赋值操作符重载的机制。由于函数参数默认也是传值:

Fun(String s);

这就引发了复制构造函数。由于函数直接返回一个对象是自然的,也是方便的,所以这时候如果返回大的容器对象,值语义就比较糟糕了,

std::vector<int> Fun(void)
{
    
    return intArray;
}

这就引发了移动语义,std::move,移动赋值操作符重载,移动构造函数。

21 运算符重载催生了引用

C++里面很多语法特性是有先后顺序和依赖关系的,这才是最有意思的事情。对于理解这个语言的合理性和折中妥协有非常直接的帮助。

很多人要求C++应该有运算符重载的能力,因为这可以让代码看起来很舒服。

比如:

Matrix a,b,c; 
a = b * c;

但是C++是沿用C的函数参数默认传值语义的,上面的代码本质上是下面的函数:

Matrix operator * (Matrix lhs, Matrix rhs); // 运算符重载

但是上面的实现会发生拷贝,这对复杂对象来说是不可接受的,那就是要传指针:

Matrix operator (Matrix * lhs, Matrix * rhs); // 运算符重载

这样以来乘法就会写成下面这样:

Matrix a,b,c; 
a = &b * &c;

上面这样一写的话,看起来就很丑了,没人会接受,太麻烦,重载的目标就是简洁优雅,整出这么个玩意儿出来,实在说不过去。何况上面的写法在C里面已经有意义了。那就只能让C++支持引用了:

Matrix operator * (const Matrix & lhs, const Matrix & rhs);

有了上面的引用语义,一开始的 a = b * c;就变得又优雅,又没有成本了。

有了引用,将引用作为返回值也是很重要的,尤其是像string这种类型的下标运算成员函数:

char& operator [ ] (int index);
string s;
s[i] = 'c';//这里的赋值变得简洁明了。

-End-

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值