C++类对象创建全解析:从构造函数到内存管理

发布于:2025-03-16 ⋅ 阅读:(9) ⋅ 点赞:(0)

目录

对象的创建

对象的创建规则

对象的数据成员初始化

对象所占空间大小

总结

指针数据成员


对象的创建

在之前的 Computer 类中,通过自定义的公共成员函数 setBrand 和 setPrice 实现了对数据成员的初始化。实际上,C++ 为类提供了一种特殊的成员函数——构造函数来完成相同的工作。

  • 构造函数的作用:就是用来初始化数据成员的

  • 构造函数的形式:

    没有返回值,即使是void也不能有;

    函数名与类名相同,再加上函数参数列表。

构造函数在对象创建时自动调用,用以完成对象成员变量等的初始化及其他操作(如为指针成员动态申请内存等)

对象的创建规则

  1. 当类中没有显式定义构造函数时 ,编译器会自动生成一个默认 (无参) 构造函数 ,但并不会初始化数据成员;

    以Point类为例:

    #include <iostream>// 其次是C++的文件,第三方库文件放最下
    
    using std::cout;
    using std::endl;
    class Point {
        public:
            void print()
            {
                cout << "(" << _ix 
                    << "," << _iy
                    << ")" << endl;
            }
            Point()
            {
                cout << "print" << endl;
            }
        private:
            int _ix;
            int _iy;
        };
        
        void test0()
        {
            Point pt;
            pt.print();
        }
        //运行结果显示,pt的_ix,_iy都是不确定的值
        int main()
        {
            test0();
            return 0;
        }

    这说明了,当类中没有显式定义构造函数时 ,编译器会自动生成一个默认 (无参) 构造函数 ,但并不会初始化数据成员,这里我们将他不做任何处理,看是否会自动调用。

class Point {
public:
	void print()
	{
		cout << "(" << _ix 
            << "," << _iy
			<< ")" << endl;
	}
private:
	int _ix;
	int _iy;
};

void test0()
{
	Point pt;
	pt.print();
}
//运行结果显示,pt的_ix,_iy都是不确定的值

Point pt; 这种方式创建的对象,其数据成员没有被初始化,输出的会是不确定的值

image-20240307114915395

 2.  一旦当类中显式提供了构造函数时 ,编译器就不会再自动生成默认的构造函数;

class Point {
public:
    Point(){
        cout << "Point()" << endl;
        _ix = 0;
        _iy = 0;
    }
	void print()
	{
		cout << "(" << _ix 
            << "," << _iy
			<< ")" << endl;
	}
private:
	int _ix;
	int _iy;
};

void test0()
{
	Point pt;
	pt.print();
}
//这次创建pt对象时就调用了自定义的构造函数,而非默认构造函数

3. 编译器自动生成的默认构造函数是无参的,构造函数也可以接收参数,在对象创建时提供更大的自由度;

class Point {
public:
    Point(int ix, int iy){
        cout << "Point(int,int)" << endl;
        _ix = ix;
        _iy = iy;
    }
	void print()
	{
		cout << "(" << _ix 
            << "," << _iy
			<< ")" << endl;
	}
private:
	int _ix;
	int _iy;
};

void test0()
{
	Point pt;//error,没有默认的无参构造函数可供调用
    Point pt2(10,20);
	pt2.print();
}

4. 如果还希望通过默认构造函数创建对象, 则必须要手动提供一个默认构造函数;

class Point {
public:
    Point(){}
    
    Point(int ix, int iy){
        cout << "Point(int,int)" << endl;
        _ix = ix;
        _iy = iy;
    }
	void print()
	{
		cout << "(" << _ix 
            << "," << _iy
			<< ")" << endl;
	}
private:
	int _ix;
	int _iy;
};

void test0()
{
	Point pt;//ok
    Point pt2(10,20);
	pt2.print();
}

5. 构造函数可以重载

如上,一个类中可以有多种形式的构造函数,说明构造函数可以重载。事实上,真实的开发中经常会给一个类定义各种形式的构造函数,以提升代码的灵活性(可以用多种不同的数据来创建出同一类的对象)。

image-20240307120328878

对象的数据成员初始化

上述例子中,在构造函数的函数体中对数据成员进行赋值,其实严格意义上不算初始化(而是算赋值)。

在C++中,对于类中数据成员的初始化,推荐使用初始化列表完成。初始化列表位于构造函数形参列表之后,函数体之前,用冒号开始,如果有多个数据成员,再用逗号分隔,初始值放在一对小括号中。

class Point {
public:
	//...
	Point(int ix = 0, int iy = 0)
	: _ix(ix)
	, _iy(iy)
	{
		cout << "Point(int,int)" << endl;
	}
	//...
};

如果没有在构造函数的初始化列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。如在“对象的创建规则”示例代码中,有参的构造函数中 _ix 和 _iy 都是先执行默认初始化后,再在函数体中执行赋值操作。

image-20240307143824881

补充:数据成员的初始化并不取决于其在初始化列表中的顺序,而是取决于声明时的顺序(与声明顺序一致)

  • 构造函数的参数也可以按从右向左规则赋默认值,同样的,如果构造函数的声明和定义分开写,只用在声明或定义中的一处设置参数默认值,一般建议在声明中设置默认值。

    class Point {
    public:
    	Point(int ix, int iy = 0);//默认参数设置在声明时
    	//...
    };
    
    Point::Point(int ix, int iy)
    : _ix(ix)
    , _iy(iy)
    {
    	cout << "Point(int,int)" << endl;
    }
    
    void test0(){
        Point pt(10);
    }

  • C++11之后,普通的数据成员也可以在声明时就进行初始化。但一些特殊的数据成员初始化只能在初始化列表中进行,故一般情况下统一推荐在初始化列表中进行数据成员初始化。

class Point {
public:
	//...
    int _ix = 0;//C++11
    int _iy = 0;
};

  • 数据成员初始化的顺序与其声明的顺序保持一致,与它们在初始化列表中的顺序无关(但初始化列表一般保持与数据成员声明的顺序一致)。

对象所占空间大小

之前在讲引用的知识点时,我们提过使用引用作为函数的返回值可以避免多余的复制。内置类型的变量最大也就是long double,占16个字节。但是现在我们学习了类的定义,自定义类型对象的大小可以非常大。

使用sizeof查看一个类的大小和查看该类对象的大小,得到的结果是相同的(类是对象的模板);

void test0(){
    Point pt(1,2);
    cout << sizeof(Point) << endl;
    cout << sizeof(pt) << endl;
 }

成员函数并不影响对象的大小,对象的大小与数据成员有关(后面学习继承、多态,对象的内存布局会更复杂);

现阶段,在不考虑继承多态的情况下,我们做以下测试。发现有时一个类所占空间大小就是其数据成员类型所占大小之和,有时则不是,这就是因为有内存对齐的机制。

class A{
    int _num;
    double _price;
};
//sizeof(A) = 16

class B{
    int _num;
    int _price;
};
//sizeof(D) = 8

  • 为什么要进行内存对齐?

    1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

    2.性能原因:CPU 对内存的读取不是连续的,而是分成块读取的,块的大小只能是1、2、4、8、16 ... 字节。若不进行内存对齐,可能需要做两次内存访问,性能会大打折扣;而进行过内存对齐仅需要一次访问。

    image-20240223163737665

    64位系统默认以8个字节的块大小进行读取。

    如果没有内存对齐机制,CPU读取_price时,需要两次总线周期来访问内存,第一次读取 _price数据前四个字节的内容,第二次读取后四个字节的内容,还要经过计算,将它们合并成一个数据。

    有了内存对齐机制后,以浪费4个字节的空间为代价,读取_price时只需要一次访问,所以编译器会隐式地进行内存对齐。

    规则一:

    按照类中占空间最大的数据成员大小的倍数对齐;

    如果数据成员再多一些,我们发现自定义类型所占的空间大小还与这些数据成员的顺序有关

    class C{
        int _c1;
        int _c2;
        double _c3;
    };
    //sizeof(C) = 16
    
    class D{
        int _d1;
        double _d2;
        int _d3;
    };
    //sizeof(D) = 24

    image-20240223163111420

    如果数据成员中有数组类型,会按照除数组以外的其他数据成员中最大的那一个的倍数对齐

    class E{
        double _e;
        char _eArr[20];
        double _e1;
        int _e2;
    };
    //sizeof(E) = 48
    
    class F{
        char _fArr[20];
    };
    //sizeof(F) = 20

    再判断一下,G类所占的空间是多少?

    class G{
        char _gArr[20];
        int _g1;
        double _g2;
    };//32

在C语言的涉及的结构体代码中,我们可能会看到#pragma pack的一些设置,#pragma pack(n)即设置编译器按照n个字节对齐,n可以取值1,2,4,8,16.在C++中也可以使用这个设置,最终的对齐效果将按照 #pragma pack 指定的数值和类中最大的数据成员长度中,比较小的那个的倍数进行对齐。

总结

除数组外,其他类型的数据成员中,以较大的数据成员所占空间的倍数去对齐。

内存对齐还与数据成员的声明顺序有关。

指针数据成员

类的数据成员中有指针时,意味着创建该类的对象时要进行指针成员的初始化,需要申请堆空间。

在初始化列表中申请空间,在函数体中复制内容。

class Computer {
public:
	Computer(const char * brand, double price)
	: _brand(new char[strlen(brand) + 1]())
	, _price(price)
	{
        strcpy(_brand,brand);
    }
    
private:
	char * _brand;
	double _price;
};

void test0(){
    Computer pc("Apple",12000);
}

思考一下,以上代码有没有问题?

image-20240307152000320

代码运行没有报错,但使用memcheck工具检查发现发生了内存泄漏。有new表达式被执行,就要想到通过delete表达式来进行回收。如果没有对应的回收机制,对象被销毁时,它所申请的堆空间不会被回收,就会发生内存泄漏。


网站公告

今日签到

点亮在社区的每一天
去签到