int类型,构造函数

不同平台下int类型、指针类型的数据大小

对于int类型数据和指针类型数据的大小,是非常基础的问题。

在一个具体的平台上,确定他们最好的办法就是使用sizeof(type)对其进行判断,返回当前数据类型的大小。

在不同的平台下,int类型和指针类型的数据类型大小时怎样的呢?如果要给出一个统一的答案,自然不可能集齐每个平台,一个个地去试,我们必须从底层进行分析。

数据总线和地址总线

计算机内的数据总线是CPU与外设进行数据交换的通路,而地址总线则是CPU用于寻址的通路。

数据总线的位数决定了CPU与外设一次可以传输的字节数。

而地址总线则决定了CPU可以寻址的范围,如果是32位地址线,每根地址线传输一位,可表示的范围为02^32-1,由于内存中的基本单位是byte,所以也就对应02^32-1 byte,也就是0~4GB,对应于内存的可取范围。

总线的位数一般也用总线宽度来表示。


一个常见的误区是:地址总线和数据总线的宽度总是保持一致的。

这个错误想法的缘由就是没有弄清楚地址总线和数据总线的工作原理,我们可以这样理解:

在一个很大的仓库中,地址总线代表一张仓库地图的坐标,而数据总线则是取货的推车,地图坐标系的大小(也就是地址总线的宽度)决定了它可以记录多大的仓库面积,而推车的大小(即数据总线的宽度)决定了一次可以取多少货。

仓库管理员根据地图找到货物存放的地址,然后使用推车来存取货物。

在这个模型中,仓库的面积可以非常大,只需要将地图坐标绘得足够大,而取货的推车可以很小,只是取大件货物的时候多跑几次。

同样的,管理员可以买个足够大的推车,而仓库面积并不大,只是通常在取货的时候,推车不能被装满,存在一些空间浪费问题。


所以说,地址总线和数据总线在宽度上没有太大的联系,但是,芯片设计者为了平衡时间效率和空间效率,同时考虑到硬件上的影响,通常使用同样的地址总线宽度和数据总线宽度。

举一个数据总线和地址总线宽度不同的例子:在经典的8086计算机中,数据总线为16位,地址总线则是20位。

芯片的位数

通常,我们所说的一个芯片是多少位的,到底是看它的数据总线宽度还是地址总线宽度呢?

答案是:决定一个芯片多少位,是由这个芯片一次能处理多少位数据决定的,等于片内寄存器的宽度,同时可以看成是数据总线的宽度。

为什么这里说的是芯片位数可以看成是数据总线的宽度而不是等于数据总线的宽度呢?按照目前的情况而言,几乎所有的芯片位数都等于数据总线宽度。

但是从严格意义上来说,数据总线是用来传输数据的,芯片位数指的是处理数据的宽度,数据的传输和处理并非同一个概念,传输和处理数据的宽度是可以不一样的,只是实际情况下数据传输的宽度和处理的宽度是一样的,当然,这种区分有点吹毛求疵,所以,芯片位数等于数据总线宽度也是一种可接受的答案(当然,过去都是一致不代表未来也是一致,概念还是要分清)。

同时,在编程时,我们通常碰到一个叫做"字长"的概念,字长通常等于数据总线的宽度。

int型数据的大小

常见的第二个误区是:int型数据的大小,也就是sizeof(int)的大小完全跟随硬件平台的位数。

这个误区的产生是源于我们初学C语言时的教程:在16位芯片上int型类型大小为16位,即两字节,而在32位机器上,int型为32位,即四字节。 以此类推,由此我们就建立的一个模糊且错误的概念:int型数据的大小是跟随于平台的位数。

事实上,正确的答案是:int型数据的大小和硬件平台位数无关,它是由C语言标准和编译器共同决定的。

为此,博主查阅了C99 spec标准,它是这么说的:

Sizes of integer types <limits.h>
The values given below shall be replaced by constant expressions suitable for use in #ifpreprocessing directives.
...
...
minimum value for an object of type int
INT_MIN -32767 // −(215 − 1)           //这只是其中一个示例,不同平台可能有不同定义
— maximum value for an object of type int
INT_MAX +32767 // 215 − 1

翻译过来就是,int类型的大小是由limits.h文件中INT_MIN和INT_MAX两个宏定义来决定的,而limits.h文件在编译器库文件中可以找到。

int类型对应平台的大小是这样的:

  • 16位系统中,int型为16位大小,两字节
  • 32位系统中,int型为32位大小,四字节
  • 64位系统中,int型为32位大小,四字节

事实上,除了int类型,还有一个类型在不同平台中有不同的表现,那就是long型:

  • 16位系统中,long型为32位大小,4字节
  • 32位系统中,long型为32位大小,4字节
  • 64位系统中,long型为64位大小,8字节

指针的大小

对于指针变量的大小,我听得最多的一个概念就是:在32位系统下指针类型为32位,在64位系统下指针类型为64位,以此类推。

但是不得不遗憾地说,这个说法其实是错误的,至少说是不严谨的。

指针本质上是变量,它的值是内存中的地址,既然需要通过指针能够访问当内存当中所有的数据,那么这个指针类型的宽度至少要大于等于地址总线的宽度。打个比方一个芯片的地址总线是32位,那么内存地址的范围就是0~4G,那么这个指针类型的宽度至少需要32位,才能保证访问到内存中每个字节。

但是,实际上的情况是:芯片的位数由芯片一次能处理的数据宽度决定,可看成是数据总线的宽度,但是地址总线和数据总线的宽度有时候并不一致。

所以在经典的32位系统中,同时也是32位地址总线,自然而然的,指针的长度为32位。

但是对早期的8086而言,这是16位芯片,但是它的地址总线却扩展到了20位,同时因为数据对齐的原因,它的指针大小应该是16+16位=32位,但是出于效率上的优化,8086提供了远指针、近指针,在访问本段内的地址时,采用16位指针,如果有段地址跳转,就使用32位的指针。

至少从这个示例可以知道,指针的大小完全由实际使用的地址总线的宽度(+数据对齐)来决定,而并非由芯片位数来决定。

所以,有时候,我们可能会在64位系统中碰到指针大小为4字节的情况,也可能在16位系统中碰到指针大小为4字节的情况。

当然,需要特别注意的是,在64位系统中地址大小为4字节的情况下,并非一定是芯片的地址总线是32位,很可能是CPU运行在只使用部分地址总线的模式下,又或者是使用32位兼容的编译器所致,这一部分较为复杂,本文旨在建立一个初步的概念,如果要深入研究的话几篇博客是不够的,这里暂不赘述。

总结

int和long类型数据大小并非由硬件平台的位数决定,而是由C标准和编译器共同决定。

同时,指针即sizeof(ptr)的大小也并非由硬件平台的位数决定,而是由实际上所使用的地址总线宽度决定的。


C++构造函数的理解

相对于C语言来说,C++有一个比较好的特性就是构造函数,即类通过一个或者几个特殊的成员函数来控制其对象的初始化过程。构造函数的任务,就是初始化对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的语法

构造函数的名字必须和类名相同,与其他函数不一样的是,构造函数没有返回值,而且其必须是公有成员,因为私有成员不允许外部访问,且函数不能声明为const类型,构造函数的语法是这样的:

class Test
{
    public:
        Test(){std::cout<<"Hello world!"<<std::endl;}
};
Test object; 
int main(){return 1;}

在main函数执行之前,object被定义时就会调用Test函数,输出"Hello world!"。

这里只是示范了一个最简单的构造函数的形式,其实构造函数是个比较复杂的部分,有非常多神奇的特性。

构造函数的种类

默认构造函数

当我们程序中并没有显式的定义构造函数时,系统会提供一个默认的构造函数,这种编译器创建的构造函数又被称为合成的默认构造函数,合成构造函数的初始化规则是这样的:

  • 如果存在类内的初始值,用它来初始化成员。在C++11的新特性中,C++11支持为类内的数据成员提供一个初始值,创建对象时,类内初始值将用于初始化数据成员。如果在构造函数中又显式地初始化了数据成员,则使用显式初始化的值。
  • 否则,默认初始化该成员。默认初始化意味着和C语言一样的初始化方式,当类对象为全局变量时,在系统加载时初始化为0,而作为局部变量时,由于数据在栈上分配,成员变量值不确定。

需要注意的是,只有当用户没有显式地定义构造函数时,编译器才会为其定义默认构造函数。

在某些情况下,默认构造函数是不合适的:

  • 如上所说,内部定义的类调用默认构造函数会导致成员函数的值是未定义的。

  • 如果类中包含其他类类型的数据成员或者继承自其他类,且这个类没有默认构造函数,那么编译器将无法初始化该成员。上面提到了可以在类内给成员一个初始值,但是这只对于普通变量,并不支持类的构造。
    当我们除了自定义的其他构造函数,还需要一个默认构造函数时,我们可以这样定义:

    Test() = default;
    这个构造函数不接受任何参数,等于默认构造函数。

初始化列表的构造方式

首先,我们先需要分清初始化和赋值的概念,初始化就是在新创建对象的时候给予初值,而赋值是在两个已经存在的对象之间进行操作。在构造方式上,这两种是不同的。

构造函数支持初始化列表,它负责为新创建的对象的一个或者几个数据成员赋初值,初始化列表的语法是这样的:

class Test
{
    public:
        Test(int a):x(a),y(2){}
        int x;
        int y;
};

初始化的列表的一个优势是时间效率和空间效率比赋值要高,同时在const类型成员的构造时,普通的赋值构造函数是非法的。当我们创建一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其常量属性。

所以我们可以用这种方式为const成员变量写值。

拷贝构造函数

拷贝构造函数的一般形式是这样的:

class Test
{
    public:
        Test(const Test &ob){
            x = ob.x;
            y = ob.y;
        }
    private:
        int x;
        int y;
};

可以很清楚地看出来,构造过程就是将另一个同类对象的成员变量一一赋值,const修饰是因为限定传入对象的只读属性。看到上面的示例,不知道有没有朋友有所疑问:

为什么在构造函数中,用户可以访问到外部同类对象ob的私有变量,不是说私有变量只能通过类的公共函数(一般是get()方法)来访问吗,为什么这里可以直接使用ob.x,ob.y ??

如果你有这样的问题,首先不得不承认你是个善于观察且有一定基础的学者,但是对封装的概念并不是很清楚。

其实不仅仅构造函数可以访问同类对象的私有变量,普通成员函数也可以访问:

class Test
{
    public:
        Test(){};
        void func(const Test& ob){
            std::cout<<ob.x<<std::endl;
        }
<span class="hljs-keyword">private</span>:
    <span class="hljs-keyword">int</span> x=<span class="hljs-number">2</span>;

};

这样的写法不会报错且能够正常运行,但是如果func()的函数是这样的:

void func(const AnotherClass& ob){
            std::cout<<ob.x<<std::endl;
        }

那我们还能不能访问ob的私有变量呢?答案肯定是不行的,这不用说。那我们回到上面的问题,为什么可以访问同类对象的私有变量?

其实答案并不难理解,类的封装性是针对类而不是针对类对象。

通俗地来说,我们定义类中成员访问权限的初衷是为了保护私有成员不被外部其他对象访问到,一般情况下私有成员被外部访问的方式就是通过公共的函数接口(public),而在类的内部,任何成员函数都能访问私有成员,这种保护是针对不同的类之间的,所以我们是在定义类的时候来指定访问权限,而不是在定义对象的时候再指定访问权限。

再者,相同类对象,对于所有的私有变量,彼此知根知底,也就没有什么保护的必要。

既然是这样,类内的构造函数以及其它函数都是类的成员函数,自然可以访问所有数据。

赋值运算符重载

同时,类的构造可以用重载赋值运算符来实现,即"="。

class Test
{
    public:
        Test& operator=(const Test &ob){
            x = ob.x;
            y = ob.y;
            return this;
        }
    private:
        int x;
        int y;
};

在定义类的时候,我们可以这样:

Test ob1;
Test ob2 = ob1;

默认拷贝构造函数的陷阱

当我们没有指定拷贝构造函数或者没有重载赋值运算符时,系统会生成默认的对应构造函数,分别为合成拷贝构造函数和合成拷贝赋值运算符。即使用户没有在类中定义相对应拷贝赋值操作,我们照样可以使用它:

Test ob1;
Test ob2(ob1);
Test ob3 = ob2;

编译器生成的默认拷贝赋值构造函数会将对应的成员一一赋值,是不是非常方便?

既然编译器生成的默认拷贝赋值构造函数就能完成任务,为什么我们还要自己去定义构造函数呢?这是不是多此一举?

非也!!!

如果类型成员全部都是普通变量是没有问题的,但是如果涉及到指针,简简单单地复制指针也是没有问题的,最要命的是如果指针指向的动态内存,这样就会有两个不同类的成员指向同一片动态内存,而析构函数在释放内存时,必然造成double free,我们可以看下面的例子:

class Test
{
    public:
        Test(){p = new int(4);}
        ~Test(){delete p;}
        int *p;
};
Test ob1;
Test ob2 = ob1;
int main(){}

然后编译运行:

g++ -std=c++11 test.cpp -o test
./test

这段程序不做任何事,仅仅是通过编译器生成的合成拷贝赋值运算符,运行结果:

*** Error in `./a.out': double free or corruption (fasttop): 0x085dca10 *** Aborted (core dumped)

很明显,和上面所提到的一样,动态内存的double free导致程序终止。为了观众朋友们能更清晰地理解这个过程,我们再来对程序做一个step by step解析:

  • 构造类对象ob1,这是调用了构造函数,为ob1.p分配了内存空间。
  • 用合成拷贝赋值构造函数构造类对象ob2 = ob1,相当于执行了语句:ob2.p = ob1.p;
  • main()函数执行完毕,全局函数的运行周期结束,系统回收内存,先调用ob1的析构函数,将ob1.p指向的内存释放。
  • 调用ob2的析构函数,将ob2.p指向的内存释放,但是由于ob2.p的内存已经在上一步被释放,所以造成了double free。

事实上,这种现象在C++中有两个专用名词来描述:"浅拷贝"和"深拷贝"。

所以,在使用编译器默认的合成构造函数时,我们要非常小心这一类的陷阱,即使是目前没有指针成员函数,也要自己写拷贝赋值构造函数,这样有利于代码的扩展和维护。

但是,话说回来,如果我每次实现一个很简单的需求,都要定义复制拷贝构造函数,一个一个成员去赋值,这样也是很烦人的,在新标准下,C++提供了一种方法来"解决"这个问题。

阻止拷贝

用户可以禁止使用拷贝函数,只要作这样声明:

Test(Test &ob) = delete;
Test &operator(Test &ob) = delete;
事实上,部分编译器默认禁止合成的拷贝赋值构造函数。 

这样,在使用者想使用默认的拷贝赋值构造函数时,编译器将无情地报错。


移动构造函数

在说到移动构造函数之前,我们得先介绍一下新标准下一种新的引用类型——右值引用。右值引用就是必须绑定到右值的引用,左值的引用用&,而右值的引用则用&&。右值引用有一个重要的性质,即只能绑定到一个将要销毁的对象。

通俗地说,右值通常为临时变量,字面值,未接受的返回值等等,它们没有固定地址。
而左值通常是变量。总而言之,左值持久,右值短暂。  

下面是引用和右值引用的示例:

int x = 30;
int &r = x;  //正确,左值引用
int &&r = x; //错误,x为左值,&&r为右值引用
int &&r = 3; //正确,右值引用
const int &r = 3;  //正确,const左值可以对右值引用

由于右值引用只能绑定到临时对象,我们可以知道它的特点:

  • 所引用的对象将要被销毁
  • 该对象没有其他用户
    这两个特性则意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。可想而知,右值引用的特点是"窃取"而不是"生成",在效率上自然就有所提高。

如果现在有一个左值,我们想将它作为右值来处理,应该怎么办呢?答案是std::move()函数,语法是这样的:

int x = 30;
int &&r = std::move(x); 

但是正如右值的特性而言,将左值转换成右值的时候,你得确保这个左值将不再使用,建议使用std::move(),因为这样的函数名总是容易出现命名冲突。

让我们再回到移动构造函数,各位朋友们应该从前面的铺垫已经猜到了这是个什么样的实现,是的,它的特点就是接受一个右值作为参数来进行构造。实现是这样的:

class Test
{
    public:
        Test(){p = new int(10);}
        ~Test(){delete p;}
        Test(Test &&ob) noexcept{
            p = ob.p;
            ob.p = nullptr;
        }
        int *p;
};

可能朋友们看了上面的实现会有两个疑问:

  • 为什么函数要加上noexcept声明?
  • 为什么要加上 ob.p=nullptr 这个操作?
    刚刚我们提到了拷贝赋值构造函数的浅拷贝问题(即指针部分仅仅是复制),很显然,那样是不行的。但是在移动构造函数中,我们依然是浅拷贝,为什么这样又可以?

从上面的示例可以看出移动构造函数的参数是一个右值引用,我们上面有提到,移动构造函数的特点是"窃取"而不是生成。就相当于将目标对象的内容"偷过来",既然目标对象的内存本来就是存在的,所以不会因为失败问题而抛出异常。当我们编写一个不抛出异常的移动操作时,有必要通知标准库,这样它就不会为了可能的异常处理而做一些额外工作,这样可以提升效率。

再者,我们将右值对象的内容偷过来,但是右值对象依然是存在的,它依旧会调用析构函数,如果我们不将右值的动态内存指针赋值为null,右值对象调用析构函数时将释放掉这部分我们好不容易偷过来的内存。就像上面的例子所示,我们不得不将ob.p指针置为空。
口说无凭,我们来看下面的示例:

class Test
{
    public:
        Test(void){p=new int(50);
		}
        Test(Test &&ob) noexcept{
            p = ob.p;
			//ob.p = nullptr;     
        }
		~Test(){delete p;}
        int *p;
};
Test ob1;
int main()
{
    Test ob2 (std::move(ob1));
}

在示例中,我们将ob.p = nullptr;这条语句注释,然后使用无参构造函数构造ob1,然后将ob1转为右值来构造ob2.我们来看运行结果:

*** Error in `./a.out': double free or corruption (fasttop): 0x09f12a10 ***
Aborted (core dumped)

果然如我所料,出现了double free的错误,这是因为在移动构造函数中传入的右值对象ob在使用完后调用了析构函数释放了p,而对象ob2偷到的仅仅是一个指针的值,指针指向的内容已经被释放了,所以在程序执行完成之后再调用析构函数时就会出现double free的错误。
为了再验证一个问题,我们将上面的例子中加上ob.p = nullptr;,并将main()函数改成这样:

class Test
{
    public:
        Test(void){p=new int(50);
		}
        Test(Test &&ob) noexcept{
            p = ob.p;
			ob.p = nullptr;
        }
		~Test(){delete p;}
        int *p;
};
Test ob1;
int main()
{
    Test ob2 (std::move(ob1));
    cout<<*ob1.p<<endl;
}

我们来看看已经被转换成右值的ob1个什么情况,运行结果是这样的:

Segmentation fault (core dumped)

好吧,其实这是显而易见的,ob1.p已经在移动构造函数中被置为nullptr了。

为什么C++11要添加这个新的特性呢?从效率上出发,在程序运行的时候,由于中间过程会出现各种各样的临时变量,每创建一个临时变量,就会多一次对资源的构造和析构的消耗,如果我们能将临时变量的资源接管过来,就可以省下相应的构造和析构所带来的消耗。

隐式转换构造函数

C++中,当类有一个构造函数接收一个实参,它实际上定义了转换为此类类型的隐式转换机制,又是我们把这种构造函数称为转换构造函数。

官方解释总是像数学公式一样难以理解,通俗地说,当一个类A有其中一个构造函数接受一个实参(类型B)时,在使用时我们可以直接使用那个构造函数参数类型B来临时构造一个类A的对象,好像我也没解释清楚?好吧,直接上代码看:

class Test{
public:
    Test(string s,int para = 1){
        str = s;
    }
    void add(Test ob){
        str += ob.str;
    }
    string str;
};
Test ob1("downey");
int main()
{
    ob1.add(string("downey!"));
    cout<<ob1.str<<endl;
}

运行结果:

downeydowney!

如码所示,Test类有一个构造函数,可以接收一个string类的实参(可以由一个实参构造并不代表只能有一个形参),而add()方法接受一个Test类类型参数,在调用add()方法时,我们直接传入一个string类型,触发隐式转换功能,编译器将自动以string作为实参构造一个Test的临时类对象来传入add()方法,程序结束之后将释放临时变量。
需要注意的是,隐式转换只支持一次转换,如果我们将main()函数改成这样:

int main()
{
    ob1.add("downey!");   
    cout<<ob1.str<<endl;
}

编译器需要将"downey"转换成string类型,然后再进行一次转换,这样是不支持的。在编译阶段就会报错:

error: no matching function for call to XXX

同时,如果我们在声明add()函数时习惯性地使用了左值引用:

void add(Test &ob){      //使用引用,&
        str += ob.str;
    }

这样又是什么结果呢?

答案是,编译出错。这又是为什么?如果你有仔细看上面的隐式转换过程就可以知道,在使用隐式转换时生成了一个临时变量(类型同函数形参),而临时变量是右值,是不能使用左值引用的。报错信息如下:

error: no matching function for call to XXX  //左值引用不匹配,所以这里找不到匹配的方法。
阻止隐式转换

使用explicit关键字修饰函数可以阻止构造函数的隐式转换,而且explicit只支持直接初始化时使用,也就是在类内使用,同时,只对一个实参的构造函数有效。在STL中我们随时可以看到explicit的影子。
下面是示例:

class Test{
public:
    explicit Test(string s,int para = 1){
        str = s;
    }
    void add(Test ob){
        str += ob.str;
    }
    string str;
};
Test ob1("downey");
int main()
{
    ob1.add(string("downey!"));    //报错,no matching function for call to XXX,因为这里不支持隐式转换
    cout<<ob1.str<<endl;
}

同时,如果用户试图在类外声明时使用explicit关键字,将会报错:

error: only declarations of constructors can be ‘explicit

结语

C++真是魔鬼!!!

好了,关于C++构造函数的讨论就到此为止啦,如果朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言

原创博客,转载请注明出处!

祝各位早日实现项目丛中过,bug不沾身.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值