前言:什么是面向对象
面向对象的核心思想是将程序分解为一个对象的集合,即 属性 + 行为 。对于具有相同特征对象我们可以进一步将其抽象封装为一个 类 。在c++中我们会抽象出对象的特征创建一个类,需要创建对象时,只需要将对应的类实例化即可。
类和对象
c++ 面向对象的三大特征分别是封装,继承,多态,本编我们将了解封装的概念及其实现。
封装
封装的概念
将对象的属性和行为封装为一个整体(即创建一个类),表现生活中的事物。
封装的意义
将事物的属性和行为加以权限控制。
三种控制权限
① public:该权限下的行为或属性在类的内部或者外部都可以直接访问。
② private:该权限下的属性和行为只有在类的内部可以直接访问。
③ protected:与private权限相同,类内可以直接访问,类外不能直接访问。该权限与private权限的区别与继承有关。
封装的实例
以“学生”这个类为例创建一个类
class Student
{
private:
string s_name;
int s_ID;
int s_grade;
public:
void setNmae(string name)
{
s_name = name;
}
void setID(int id)
{
s_ID = id;
}
void setGrade(int grade)
{
s_grade = grade;
}
void showStu()
{
cout << s_name << s_ID << grade << endl;
}
};
struct 和 class 的区别:
在c++中,struct 和 class的区别就在于默认访问权限的不同
struct 的默认访问权限为public
class的默认访问权限为private
将成员属性设置为私有
顾名思义,在类中将一个类的属性的访问权限都设置为私有,而将行为的访问权限设置为公有。通过公有的行为对私有的属性进行读写。
这样操作的好处有两点:
① 可以自己控制属性的读写权限;
这点很好理解,只需要设置公有的函数就可以控制属性的读写操作。
② 对于写操作,可以检测数据的有效性;
比如,在设置学生的年级时,应该只有1 - 4 年级,如果是这以外的数据,那么就是错误的无效数据。所以我们可以将上述代码做以下的修改
void setGrade(int grade)
{
if(grade < 1 || grade > 4)
{
cout << " 输入年级有误,赋值失败 " << endl;
return;
}
s_grade = grade;
}
这样修改之后,一方面因为年级这一个属性是私有的,不能被随意访问修改,保证了有效的数据不会被修改。另一方面,设置年级的函数加以控制 ,保证了每次为私有的属性赋的值都是合法的,保证了数据的有效性。
对象的初始化和清理
构造函数和析构函数
构造函数:创建对象时为对象的成员属性赋值,由编译器自动调用;
析构函数:在对象销毁前调用,执行清理工作,由编译器自动调用;
创建对象时的初始化和程序结束后对象的清理是非常重要的,如果使用未初始化的对象,那么将无法访问正常的数据。使用完对象后不及时清理,内存没有释放,同样也会造成安全问题。
c++ 中利用构造函数初始化对象,利用析构函数清理对象。这两个函数是编译器默认提供的,如果在我们的程序中没有写这两个函数程序将自动调用默认提供的函数,否则会调用我们的函数。(编译器提供的函数是空实现)
下面是函数的实现:
构造函数:
student()//模拟编译器的空实现
{
cout << "构造函数的调用" << endl;
}
① 构造函数没有返回值,也不用写void;
② 构造函数可以有参数,因此可以发生函数重载,创建不同类型的构造函数;
③ 函数名与类名相同;
析构函数:
~student()//模拟编译器的空实现,输出一行语句是为了判断函数是否调用
{
cout << "析构函数的调用" << endl;
}
① 析构函数没有返回值也不用写void;
② 析构函数不能添加参数,故也不能发生函数重载;
③ 析构函数名与类名相同,但要在前面添加~;
将创建学生类的函数添加构造函数和析构函数后优化为:
class Student
{
private:
string s_name;
int s_ID;
int s_grade;
public:
void setNmae(string name)
{
s_name = name;
}
void setID(int id)
{
s_ID = id;
}
void setGrade(int grade)
{
s_grade = grade;
}
void showStu()
{
cout << s_name << s_ID << grade << endl;
}
student()//模拟编译器的空实现
{
cout << "构造函数的调用" << endl;
}
~student()//模拟编译器的空实现,输出一行语句是为了判断函数是否调用
{
cout << "析构函数的调用" << endl;
}
};
构造函数的分类和调用
按照参数分类:有参构造和无参构造
有参构造:
student(string name, int ID, int grade)
{
s_name = name;
s_ID = ID;
s_grade = grade;
cout << "有参构造函数的调用" << endl;
}
无参构造,也称为默认构造函数:
student()
{
cout << "无参构造函数的调用" << endl;
}
按照类型分类:普通构造和拷贝构造
普通构造函数即为有参构造函数和无参构造函数,下面为拷贝构造函数的实现:
studen(const student &stu1)
{
s_name = sut1.s_name;
s_ID = stu1.s_ID;
s_grade = stu1.s_grade;
cout << "拷贝构造函数的调用" << endl;//输出一行,方便后续结论的验证;
}
① 使用const修饰是为了防止在拷贝过程中stu1被意外修改;
② 用引用的方式传参是为了防止无限递归。我们知道,如果不用引用的方式传参,而是用const student stu1传参,那么函数则会创建一个形式参数接收数据,而编译器创建形式参数是通过拷贝构造函数创建的,所以程序会一直递归下去,因此我们要用引用传参;
为类加入构造函数
class Student
{
private:
string s_name;
int s_ID;
int s_grade;
public:
void setNmae(string name)
{
s_name = name;
}
void setID(int id)
{
s_ID = id;
}
void setGrade(int grade)
{
s_grade = grade;
}
void showStu()
{
cout << s_name << s_ID << grade << endl;
}
student()//模拟编译器的空实现
{
cout << "构造函数的调用" << endl;
}
student(string name, int ID, int grade)
{
s_name = name;
s_ID = ID;
s_grade = grade;
cout << "有参构造函数的调用" << endl;
}
studen(const student &stu1)
{
s_name = sut1.s_name;
s_ID = stu1.s_ID;
s_grade = stu1.s_grade;
cout << "拷贝构造函数的调用" << endl;
}
~student()//模拟编译器的空实现,输出一行语句是为了判断函数是否调用
{
cout << "析构函数的调用" << endl;
}
};
三种调用方式:
① 括号法
void test01()
{
//调用默认构造函数:
student stu1;
//调用有参构造函数:
student stu2(ZhangSan, 12345, 2);
//调用拷贝构造函数:
student stu3(stu2);
}
注意事项:
调用默认构造函数时不用加括号,因为如果写成 “student stu1()” 编译器会认为这是函数声明。
② 显示法
void test02()
{
//调用默认构造函数:
studnet stu1;
//调用有参构造函数:
student stu2 = student(ZhangSan, 12345, 2);
//调用拷贝构造函数:
student stu3 = student(stu2);
}
注意事项:
这里调用有参构造函数和拷贝构造函数相当于是在等号的右侧创建一个匿名对象,在等号的左侧用stu2/3接收。匿名对象在当前行执行结束之后就会立即销毁。
不能用拷贝构造函数创建匿名对象,因为编译器认为 student(stu2) == student stu2 ,因为是拷贝构造函数,所以前面一定已经定义了stu2,所以这样会造成重定义的错误。
③ 隐式转换法
void test03()
{
//无参构造函数
student stu1;
//有参构造函数
student stu2 = {ZhangSan, 12345, 2};//相当于student stu2 = student(ZhangSan, 12345, 2);
//拷贝构造函数
student stu3 = stu2;//相当于student stu3 = student(stu2);
}
总结:
括号法是最方便的方法,并且不易出错。显示法接受匿名对象的方法符合我们使用有返回值的函数的习惯,更易理解,同样操作方便不易出错。隐式转换法更像给变量赋值,但在有多个成员属性时,不同的编译器的使用方法可能不同。
拷贝构造函数的调用时机
① 拷贝已经创建的对象构造新的对象:
这个场景上面已经举例说明,这是拷贝构造函数最基本的用法,不再做过多的赘述。
void test04()
{
student stu1(ZhangSan, 12345, 2);
student stu2(stu1);
}
② 值传递的方式给函数传参数:
void func01(student stu)
{
cout << "func函数的调用" << endl;
}
void test05()
{
student stu1;
func01(stu1);
}
通过运行test05函数的输出结果我们可以判断,在这个过程中编译器调用了拷贝构造函数。通过以前的学习我们知道,值传递给函数穿参数时,函数得到的是一份拷贝得到的形式参数。同样,这里func01函数就是调用拷贝构造函数创建了一个stu1的形式参数;
③ 对象作为函数的返回值:
student func02()
{
student stu;
cout << "func02函数的调用" << endl;
return stu;
}
void test06()
{
student sut1 = func02;
}
通过调用test06函数的输出结果我们可以判断编译器调用了拷贝构造函数。这是因为在func02函数返回stu时并不是直接返回stu,因为stu的作用域是func02函数,运行结束后stu就会销毁。func02函数是通过拷贝构造函数创建一个匿名对象作为返回值给stu1接收。
构造函数的调用规则
默认构造函数,拷贝构造函数,析构函数都是c++编译器默认给一个类自动添加的。但构造函数的调用有以下两个规则:
① 当我们添加了有参构造函数时,编译器将不会提供默认构造函数,但会提供拷贝构造函数。
class Student
{
private:
string s_name;
int s_ID;
int s_grade;
public:
void setNmae(string name)
{
s_name = name;
}
void setID(int id)
{
s_ID = id;
}
void setGrade(int grade)
{
s_grade = grade;
}
void showStu()
{
cout << s_name << s_ID << grade << endl;
}
//student()//默认构造函数
//{
// cout << "构造函数的调用" << endl;
//}
student(string name, int ID, int grade)
{
s_name = name;
s_ID = ID;
s_grade = grade;
cout << "有参构造函数的调用" << endl;
}
studen(const student &stu1)
{
s_name = sut1.s_name;
s_ID = stu1.s_ID;
s_grade = stu1.s_grade;
cout << "拷贝构造函数的调用" << endl;
}
~student()//模拟编译器的空实现,输出一行语句是为了判断函数是否调用
{
cout << "析构函数的调用" << endl;
}
};
我们注释掉默认构造函数,但是保留有参构造函数和拷贝构造函数
void test07()
{
student stu1;
}
此时如果我们运行test07函数编译器将会报错无法找到合适的默认构造函数。因为我们提供了有参构造函数,但没有提供默认构造函数,test07中我们创建对象的方式需要调用默认构造函数,所以程序将无法运行;
② 当我们定义了拷贝构造函数,编译器将不会提供其他构造函数。
和第一条规则同理,如果我们定义了拷贝构造函数我们就必须提供默认构造函数和有参构造函数,否则我们将无法使用这两个函数创建对象。
深拷贝与浅拷贝
浅拷贝:简单的赋值拷贝操作;
深拷贝:在堆区重新申请空间,进行拷贝操作;
编译器提供的拷贝构造函数是通过浅拷贝进行拷贝操作的,当类中有成员属性为指针类型时就会暴露浅拷贝的问题。在下面的代码中,我们注释掉自己写的拷贝构造函数,使用编译器提供的拷贝构造函数,并添加一个新的成员属性班级,并用指针储存。
class Student
{
private:
string s_name;
int s_ID;
int s_grade;
int* s_class;
public:
void setNmae(string name)
{
s_name = name;
}
void setID(int id)
{
s_ID = id;
}
void setGrade(int grade)
{
s_grade = grade;
}
void setClass(int a)
{
*class = a;
}
void showStu()
{
cout << s_name << s_ID << grade << *s_class << endl;
}
student()//模拟编译器的空实现
{
cout << "构造函数的调用" << endl;
}
student(string name, int ID, int grade, int c)
{
s_name = name;
s_ID = ID;
s_grade = grade;
s_class = new int(c);
cout << "有参构造函数的调用" << endl;
}
//studen(const student &stu1)
//{
// s_name = sut1.s_name;
// s_ID = stu1.s_ID;
// s_grade = stu1.s_grade;
// cout << "拷贝构造函数的调用" << endl;
//}
~student()//模拟编译器的空实现,输出一行语句是为了判断函数是否调用
{
if(s_class != NULL)
{
delete s_class;
s_class = NULL;
}
cout << "析构函数的调用" << endl;
}
};
以上代码的修改也体现了析构函数的作用。在此之前,我们所定义的变量所在的空间都是由编译器开辟储存在栈区的,释放也是由编译器自动释放的,所以在析构函数中我们不需要有任何操作。
而在加上指针后我们在使用有参构造函数时在堆区手动开辟了空间,在程序结束后也需要我们手动释放,所以在析构函数中添加了释放堆区空间的代码。(如果使用默认构造函数,指针指向的空间还是在栈区,不需要我们手动释放)
而一下代码将会体现浅拷贝的危害
void test08()
{
student stu1(ZhangSan, 12345, 2, 3);
student stu2(stu2);
}
执行test08编译器将会在执行过程中报错,下面我们从浅拷贝的本质说明原因;
编译器提供的拷贝构造函数为
void student(const student &stu)
{
s_name = stu.s_name;
s_ID = stu.s_ID;
s_grade = stu.s_grade;
s_class = stu.s_class;
}
这样我们可以知道,在test08中,stu1.s_class和stu2.s_class指向的是同一块堆区内存,我们用A代指这块内存。
这个程序的错误出现在test08运行结束后释放内存的过程,首先stu1执行析构函数A先被释放,然后stu1.s_class被赋值为NULL。然后stu2执行析构函数,stu2.s_class指向的内存将被释放,而这款内存就是A,堆区内存A被重复释放了两次,这就是编译器报错的原因。
为了在程序中规避浅拷贝的危害,我们需要自己提供拷贝函数,并进行深拷贝;
void person(const student &stu)
{
s_name = stu.s_name;
s_ID = stu.s_ID;
s_grade = stu.s_grade;
s_class = new int(*stu.s_class);
}
重新开辟内存并赋值便是深拷贝的操作。
初始化列表
使用有参构造函数的一般方法是:
void test08()
{
student stu1(ZhangSan, 12345, 2);
}
用初始化列表优化默认构造函数:
person():s_name(Zhangsan),s_ID(12345),s_grade(2)
{
cout << "默认构造函数的调用" << endl;
}
void test09()
{
student stu;
cout << stu.s_name << stu.s_ID << stu.s_grade << endl;
}
这样使用默认构造函数也可以为对象的成员属性赋值;
对初始化列表进一步优化得到:
person(string a, int b, int c):s_name(a), s_ID(b),s_grade(c)
{
cout << "默认构造函数的调用" << endl;
}
这样就能使用默认构造函数为对象的成员属性赋一定的值,虽然这样可能和有参构造函数相似,但是这样写相当于直接声明一个有初始值的对象,省去了为成员属性赋值的操作,效率更高。并且初始化列表还与继承时子类调用父类的构造函数有关。并且如果成员属性中有const类型,引用类型,或者含有没有默认构造函数的自定义类型的成员属性就必须要用初始化列表赋值。 这一点在类做成员属性中还会进一步说明。
另外要说的区分有参构造函数和初始化列表的点是初始化 和 赋值 的区别。初始化只能进行一次,而赋值可以多次进行。举一个简单的例子:
class A
{
private:
int a;
int b;
public:
A(int x, int y):a(x), b(y){}
/*如果改写为A(int x, int y):a(x), b(y), a(3){},就是错误的,因为初始化只能进行一次,
它是定义一个元素最开始的值,就像人只能出生一次;*/
A(int x, int y)
{
a = x;
b = y;
a = 3;
}
//这里a赋值了两次,但任然是正确的,因为int类型的变量是允许重复赋值的
};
一个类作为另一个类的成员属性
这里为了方便说明我们举一个简单的例子
class A
{
public:
A(int x)
{
num = x;
}
private:
int num;
};
class B
{
public:
B(int x, int y):ret(x), a(y){}
private:
int ret;
A a;
};
我们做以下说明:
① A类中由于我们提供了有参构造函数,所以编译器不会提供默认构造函数,所以在B类中A作为B的一个属性,就是我们上面所提到的没有默认构造函数的自定义类型的成员属性。因此我们在B类中必须要使用初始化列表。
② B类中的初始化列表的中的a(y)相当于是我们前面所提到的用隐式转化法调用构造函数,因此我们所执行的操作相当于是A a = y;
静态成员
静态成员就是在成员变量和成员函数之前加上关键字static;
① 静态成员变量:
1 所有对象共享一份数据;
2 在编译阶段分配内存;
3 类内声明,类外初始化;
class A
{
public:
static int num;//类内声明
};
int A::num = 10;//类外初始化
静态成员变量不属于某个对象,而是属于这一个类,所有对象中的静态成员变量都是他的一份拷贝;
void test10()
{
A a1;
cout << a1.num << endl;
A a2;
a2.num = 20;
cout << a1.num << " " << a2.num << endl;
}
通过test10的执行结果我们可以验证上述特点;
基于这个特点,静态成员变量也有两种访问方式:
void test11()
{
//通过对象访问
A a;
cout << a.num << endl;
//通过类名访问
cout << A::num << endl;
}
②静态成员函数:
1 所有对象共享同一个函数;
2 静态成员函数只能访问静态成员变量;
同样的,静态成员函数也有两种访问方式
class A
{
public:
static void func()
{
cout << "func函数的调用" << endl;
cout << num1 << endl;
}
private:
static int num1;
int num2;
};
int A::num1 = 10;
void test()
{
//通过对象调用
A a;
a.func;
//通过类名调用
A::func;
}
至于静态函数只能访问静态成员变量的特点也很容易理解,在上述代码中,如果在func函数中输出num2的值那么编译器将会报错。