一、强化类的封装
1. 封装一个数组类
由于进度没有达到后面的重载,因而这个类的封装并不算太优雅。
修改与添加了源码中的部分内容。
实现一个数组,需要实现:
设置初始的容量,作为可存放数据的最大长度。这个容量后续不可修改,那么应当设置为常量,用初始化列表赋初值。而在拷贝构造函数当中,需要确保该值被赋值过来,因而不能设为常量,只能在封装私有属性不开放修改部分,则获取容量的函数为
const
修饰的函数。存储数据的位置,需要开辟一片动态空间。动态指针使用
new
操作符,对应的在后续的析构函数中需要使用delete
操作符释放这片空间。数组的实际长度,这个长度会随着数组内数据的数量变化而变化。
构造方法:
- 插入数据,
push
- 设置某索引的数据,
setData
- 获取某索引的数据,
getData
- 获取当前容量,
getCapacity
- 获取当前长度,
getSize
- 打印数据,
printMyArray
,输出大小容量和具体数据
- 插入数据,
扩展方法:
pop
删除尾部数据endNum
获取尾部数据
C++
是强类型语言,如果0 == size
,就没有属于,当使用者调用endNum
是应当抛出错误,无法获取,如果是动态语言如js
可以返回undefined
或null
,但C++
不行,必须返回定义好的类型。进度没有到错误处理,而且在使用的过程中应当注意该方法的使用,C++
尽量不做错处处理,而是尽量避免错误,因为try catch
的效率很低。
由于进度没到模板类,就直接使用int
作为数组成员类型了。
首先定义头文件,声明一个类:
// MyArray.h #ifndef MYARRAY_TEST_MYARRAY_H #define MYARRAY_TEST_MYARRAY_H class MyArray { private: // 容量 int capacity; // 长度 int size; // 数组首元素地址 int *addr; public: MyArray(); explicit MyArray(int caps); // 拷贝构造函数 MyArray(const MyArray &arr); // 析构函数 ~MyArray(); public: // 尾部插入数据 void push(int data); // 尾部删除数据 void pop(); // 获取尾部数据 int endNum() const ; // 获取指定位置的数据 int getData(int pos) const; // 修改制定位置的值 void setData(int pos, int data); // 获取数组的容量(能存放的最大元素个数 int getCapacity() const; // 获取数组的实际大小 int getSize() const; // 打印数组 void printMyArray() const; }; #endif //MYARRAY_TEST_MYARRAY_H
其次在对应文件名的cpp
文件里写对应的定义:
#include "MyArray.h" #include <iostream> /** * capacity * size * *addr * */ MyArray::MyArray(): capacity(100), size(0) { addr = new int[capacity]{0}; } MyArray::MyArray(int caps): capacity(caps), size(0) { addr = new int[capacity]{0}; } MyArray::MyArray(const MyArray &arr) { // 基本信息 capacity = arr.capacity; size = arr.size; addr = new int[capacity]{0}; if (0 == size) { // 0 不赋值 return ; } // 遍历赋值 // 包含初始化数据 for (int i{0}; i<size; ++i) { addr[i] = arr.addr[i]; } } // 析构 MyArray::~MyArray() { if (nullptr != addr) { delete [] addr; // 数组 addr = nullptr; } } // 尾部插入数据 void MyArray::push(int data) { if (size >= capacity) { std::cerr << "数组已满" << std::endl; return ; } addr[size] = data; ++size; } // 获取 int MyArray::getData(int pos) const { if (pos >= size || pos < 0) { std::cerr << "位置无效" << std::endl; return -1; } return addr[pos]; } void MyArray::setData(int pos, int data) { if (pos < 0 || pos >= size) { std::cerr << "位置无效" << std::endl; return ; } addr[pos] = data; } int MyArray::getCapacity() const { return capacity; } int MyArray::getSize() const { return size; } void MyArray::printMyArray() const { std::cout << "大小: " << size << std::endl << "容量: " << capacity << std::endl; std::cout << "数据: "; if (0 == size) { std::cout << "无" << std::endl; return ; } for (int i{0}; i<size; ++i) { std::cout << addr[i] << " "; } std::cout << std::endl; } void MyArray::pop() { if (size == 0) { std::cerr << "无数据" << std::endl; return ; } addr[--size] = 0; // 初始化 } int MyArray::endNum() const { if (0 == size) { // 这里应该返回错误 std::cerr << "无数据" << std::endl; return -1; } return addr[size-1]; }
完成后,我们可以测试该类了:
// main.cpp #include "MyArray.h" #include <iostream> // 测试拷贝构造 MyArray getAArray(int caps = 60) { MyArray arr(caps); for (int i{0}; i<caps; ++i) { arr.push(i); } return arr; } int main() { // 实例化一个数组对象 MyArray arr; arr.printMyArray(); // 打印 std::cout << "插入数据" << std::endl; for (int i{0}; i<arr.getCapacity(); ++i) { arr.push(i * i); } std::cout << "输出数据" << std::endl; arr.printMyArray(); // 构造函数 auto arr2 = arr; std::cout << "arr2" << std::endl; arr2.printMyArray(); // 修改 arr[0] = 100; arr2.setData(0, 100); arr2.printMyArray(); // 测试构造函数 auto arr3 = getAArray(30); std::cout << "arr3" << std::endl; arr3.printMyArray(); arr3.pop(); arr3.pop(); arr3.printMyArray(); std::cout << arr3.endNum() << std::endl; return 0; }
2. 一些思考
想查看一下new
和delete
在类中的过程。
1. new
过程
前边我们学习了new
操作符,而该类的设计当中也使用到了:
MyArray::MyArray(): capacity(100), size(0) { addr = new int[capacity]{0}; } MyArray::~MyArray() { if (nullptr != addr) { delete [] addr; // 数组 addr = nullptr; } }
该过程中,我们对addr
的实施追踪:
上述是默认构造后的状态,我们使用了new int[caps]{0}
初始化动态空间,现在查看:
上述是构造前的状态,赋值之后会发生变化:
0x416eb0
往后移动100
位后是0x416F14
,查看该地址的内容。
new int[caps]{0}
实现了动态空间中的所有数据初始化。尝试new int[caps]
,既没有使用默认初始化,获得如下:
这是clang++
作为编译器的情况,系统会默认初始化动态空间的内容。
如果不方便调试,可以定义一个函数方便查看:
void MyArray::printALL() const { for (int i{0}; i<capacity; ++i) { std::cout << &addr[i] << ": " << addr[i] << std::endl; } }
2. delete
过程
析构前如上,接下来delete
:
这时候内存中的数据被清理弃用,地址内包含的数据丢失了,变成了另外一个数据。但这部份数据并不被任何地方使用,将addr
指向nullptr
,这部分将被彻底弃用。
可它实际还在,系统将如何处理?
第一次delete
:
第二次delete
直接弹出了程序。
而尝试每次弹出,发现每次delete
之后所产生的结果是一致的。
尝试使用gdb
调试,
每次的变化基本不一样。
在正常的delete
操作中,delete
完成之后,只是这段空间的值已经发生了变化,但最后还需要将该变量指向nullptr
,这样才完成了数据的清理。
前边是使用qtcreator
进行调试的,编译和调试器是clang++/lldb
;后面是clion
,编译和调试是g++/gdb
。
但两者产生的结果并不相同,可能是对该数据的一种操作的不同,不过在delete
过程中,产生变化是不一样的,但明显的改变的位置是一致的:给首地址一个标志,表示该区域应当释放。
尝试使用下面非数组的内容:
int main() { int *addr = new int; delete addr; return 0; }
定义变量:
delete
之后:
使用qtcreator
,delete
之后:
第二次重启程序,实现delete
:
3. 小结
在整体的实践当中,虽然不同的编译器有不同的行为,但大体上的动作是一致的。根据当前掌握的知识,尝试分析:
new
会在堆区申请一片空间,并实现编译器给予的初始化行为,一般依造类型实现该行为;delete
会给动态空间的一个标志,当完成将变量指向nullptr
之后,系统会对该片区域实现清理。
但调试的范围有限,无法验证这个行为。但编译器做了有规则的行为,虽然每个编译器都有不同的处理方式,不过最终的结果是一致的,而这个行为究竟是如何,可能需要查看编译器的相关资料。(TODO)
但应该确保一个情况,delete
完成之后,对应的空间被标志为将清除,应当立即将被delete
的变量置为nullptr
,确保原来的空间不会在被程序访问到,避免程序产生未知行为。
但究竟这中间发生了什么,我们并不清楚,先记录下来,后续有空做相关的资料查询。
二、运算符重载
运算符重载,就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。
运算符重载的目的:简化操作,让已有的运算符适应适应不同的数据类型。
语法: 函数的名字由关键字operator及其紧跟的运算符组成
比如: *重载+运算符 ==>operator+, 重载=号运算 ==>operator=
*注意: 重载运算符 不要更改 运算符的本质操作(+是数据的相加 不要重载成相减)
1. 重载 <<
#include <iostream> #include <cstring> class Person { private: char *name; int num; public: Person(char *name, int num) { this->name = new char[strlen(name)+1]; strcpy(this->name, name); this->num = num; std::cout << "有参构造" << std::endl; } ~Person() { if (nullptr == name) { delete [] name; name = nullptr; } std::cout << "析构函数" << std::endl; } public: // 普通成员函数 void printPerson() { std::cout << "name = " << name << ", num = " << num << std::endl; } private: friend std::ostream& operator<<(std::ostream& out, Person& ob); }; std::ostream &operator<<(std::ostream &out, Person &ob) { out << ob.name << ", " << ob.num; return out; } int main() { Person ob1("lucy", 18); std::cout << ob1 << std::endl; return 0; }
2. 重载 +
Person Person::operator+(Person &ob) { char *tmp_name = new char[strlen(name)+strlen(ob.name)+1]; strcpy(tmp_name,name); strcpy(tmp_name,ob.name); int tmp_num = num + ob.num; Person tmp(tmp_name, tmp_num); // 这里确保了tmp_name 不是 nullptr delete[] tmp_name; tmp_name = nullptr; return tmp; }
重载+
,不像<<
需要返回引用。
3. 重载 ++
--
自增和自减运算符分为前置和后置两类。
1. 前置运算符
以++a
为例,该运算符的行为是先+
后用,因而在设计重载的时候,我们需要保持这个行为。
#include <iostream> using std::ostream; using std::cout; using std::endl; // 写一个DAta自增的运算符 class Data { private: int a; int b; public: Data(): a(0), b(0) { cout << "无参构造" << endl; } Data(int a, int b): a(a), b(b) { cout << "有参构造" << endl; } ~Data() { cout << "析构函数" << endl; } public: // 重载 Data& operator++(); Data& operator--(); private: friend ostream &operator<<(ostream &out, Data &ob); }; ostream &operator<<(ostream &out, Data &ob) { out << "a = " << ob.a << ", b = " << ob.b; return out; } Data &Data::operator++() { // 内部实现,实现 私有成员的++ a+=1; b+=1; return *this; } Data &Data::operator--() { // 内部实现 私有成员-- a-=1; b-=1; return *this; } int main() { Data data; cout << "初始化: " << data << endl; ++data; cout << "++data: " << data << endl; cout << "++data: " << ++data << endl; cout << "--data: " << --data << endl; return 0; } /** 无参构造 初始化: a = 0, b = 0 ++data: a = 1, b = 1 ++data: a = 2, b = 2 --data: a = 1, b = 1 析构函数 */
因为在前置自增自减运算符是先作用在数据本身的,因此直接完成运算,再返回this
的引用即可。
而内部实现,写明a+=1
或是a-=1
,当然也可以直接++a
或--a
,但个人觉得a+=1
这类更加显示自增的实质。
2. 后置运算符
后置运算符是先用后运算,明显需要返回前一刻的状态。
但函数返回之后就无法再执行运算了,因而需要一个临时变量获取前一刻的状态,在执行运算,后返回前一刻状态。
而设计后置自增或者自减运算符中需要注意返回的类型是另外一个临时的变量,这个变量是不应该做其他运算的,而做了其他运算对原本的对象也是无影响的。按这个思路,应该对函数返回值定义为const
,避免使用错误使用自增运算。
不过clang-tidy
有个bug
,后置自增不添加const
就会提醒你后置应该添加const
,当你添加了const
,他会告诉你语义不清,降低可读性。
不管了。
// 方便建立临时变量,建立一个拷贝构造 //拷贝构造 Data(const Data& data): a(data.a), b(data.b) { cout << "拷贝构造" << endl; } // 实现自增自减 const Data Data::operator--(int) { Data tmp(*this); a-=1; b-=1; return tmp; } const Data Data::operator++(int) { const Data tmp(*this); a+=1; b+=1; return tmp; }
很明显,C++
识别前置和后置的是形参的形式,前置运算符是operator++(T val)
,val
是使用自增的变量,而作为类中的成员函数,val
可以忽略,编译器会自动补充为this
;而后置需要两个参数,第二个参数是一个占位符,只起到区分的作用,占位形参不会被使用,即operator++(T val, int)
,而该类型必须是int
,否则编译无法通过。
3. 思考
通过设计自增自减运算符,我们可以发现,前置自增或自检总在使用对象本身,因此内部不会产生其他变量;但后置则需要保留一个临时变量,会占用额外的空间,虽然看起来影响不大,但产生的临时变量产生之后基本是不会用到甚至无法使用的,那么很明显浪费了空间。
对比之下,明显是前置效率比较高。
4. 重载 *
->
智能指针的引入
C++
中在堆区手动申请内存后,需要手动释放,具体就是new
和delete
操作符。而用户如果忘记释放内存,则会造成内存泄露。注意内存的申请和释放是良好的编程习惯。
C11
中提供了智能指针的实现,原理简单描述即是:借助析构函数实现自动释放,重载*
->
以模拟指针类操作。
我们尝试实现一个简单的智能指针类。
一个智能指针类
尝试超个纲,确保该类适配不同的类。
// 一个智能指针类 template <class T> class SmartPointer { private: T *ptr; public: SmartPointer() { ptr = new T; } explicit SmartPointer(T* ptr) { this->ptr = ptr; } // 内存释放 ~SmartPointer() { if (nullptr != ptr) { delete ptr; ptr = nullptr; } } };
上述使用析构函数实现了自动释放,我们可以使用SmartPointer pointer(new T())
来实现了一个类的内存申请,当结束了pointer
生命周期,T
类型的指将针会在SmartPointer
调用析构函数时释放内存。
但此时不能使用这个ptr
,此处已经设置其为私有属性,想要在外部访问,则需要一个公开的方法,不过相对于指针,我们希望使用*
与->
进行访问,这就需要说到这两者的重载。
与之前的重载是一样的。
T* operator->() { return ptr; } T& operator*() { return *ptr; }
*
返回指针指向的内容,->
访问指针内的方法。我们建立一个Person
类,并尝试使用:
class Person { private: int param; public: explicit Person(int p): param(p) { std::cout << "构造" << std::endl; }; ~Person() { std::cout << "析构" << std::endl; } public: void setValue(int p) { this->param = p; } private: friend std::ostream &operator<<(std::ostream &out, Person &p); }; std::ostream &operator<<(std::ostream &out, Person &p) { out << "param=" << p.param ; return out; }
接下来调用:
int main() { SmartPointer pointer(new Person(299)); std::cout << *pointer << std::endl; pointer->setValue(100); std::cout << *pointer << std::endl; return 0; }
输出结果:
构造 param=299 param=100 析构
可以看出,我们在堆区申请Person
类的空间,而后面没有手动释放,但智能指针类帮助我们释放了内存,调用了对应的析构函数。
此处只是引入*
和->
的重载,C++11
的智能指针实现功能功能更多一些。具体可以查看文档。
5. 重载=
=
作为赋值运算符,编译器中默认会将该运算符赋值是浅拷贝,即直接将值赋过去。则类中只有普通的变量不需要重载=
,但指针特殊,指针地址在某一时刻可能会被释放,如果指针仅仅浅拷贝,则会造成某个变量访问了不应当存在的地址,造成未知的错误。
类似与拷贝构造,一旦类中含有指针,则需要实现拷贝构造函数和重载赋值运算符,确保实现深拷贝。
6. 重载==
和 !=
此处只需要保持对应的行为即可,返回的是布尔值。
7. 不应该重载 &&
和 ||
虽然逻辑运算符是可以重载的,但是运算符重载实际上还是函数调用,函数会先计算形参中的内容,如果第二个形参会影响到第一个形参,那么这个值就会发生变化,无法返回正确的结果。
因为逻辑运算符应到符合短路特性:
&&
短路特性: A && B 如果A为假,则B不会执行||
短路特性: A || B 如果A为真,则B不会执行
可作为函数,传入形参的实参会先完成计算后执行函数内容。无法实现短路特性,这违反了运算符重载的原则:保持原操作符的行为。
8. 防函数,重载()
()
会模拟函数的行为,及类对象可以直接使用foo()
,虽然foo
是一个对象,不过一旦写了()
的重载,其会调用对应的重载函数。
总结
=
,[]
,->
操作符只能通过成员函数进行重载,<<
和 >>
只能通过全局函数配合友元函数进行重载,不要重载&&
和||
,因为无法实现短路规则。
运算符 | 建议使用 |
---|---|
所有一元运算符 | 成员函数 |
= () [] -> ->* | 必须是成员函数 |
+= -= /= *= ^= &= != %= >>= <<= | 成员函数 |
其他二元运算符 | 非成员函数 |
三、一个 String
类
设计一个string
类,而string
类的实现需要用到大部分的操作符重载。
需要实现最基本的字符串创建,和字符串修改;
- 字符串的基本赋值;
char * -> MyString
MyString
类的相互赋值;MyString -> MyString
- 字符串中内容的修改;
MyString[1] = char
- 字符串的基本赋值;
字符串比较
==
!=
;MyString ?= MyString
;MyString ?= char*
;需要确保两者的顺序可以相互置换;
因而在重载相同类型比较时,需要确保两边的类型是一致的;
获取字符串长度;
输入输出的操作;
实现
#ifndef MYSTRING_MYSTRING_H #define MYSTRING_MYSTRING_H #include <iostream> using std::ostream; using std::istream; class MyString { private: char *str; int size; public: // 构造函数 MyString(); explicit MyString(const char *s); MyString(const MyString &str); ~MyString(); public: // 普通方法 // 获取容量 [[nodiscard]] int Size() const; public: // 函数重载 // + MyString operator+(const MyString& s); MyString operator+(const char *st); // str1 + str2 ; str1 + "str" // [] char& operator[](int index); // str[0] // == != bool operator==(const MyString& s) const; bool operator==(const char *st) const; bool operator!=(const MyString& s) const; bool operator!=(const char *st) const; // = // 使用指针,必须重载= MyString& operator=(const MyString& s); MyString& operator=(const char *st); private: // 重载<< friend ostream& operator<<(ostream& out, MyString& str); // 重载>> friend istream &operator>>(istream& in, MyString& str); }; #endif //MYSTRING_MYSTRING_H
具体代码实现:
#include "MyString.h" #include <iostream> #include <cstring> using std::cerr; using std::cout; using std::cin; using std::endl; using std::ostream; using std::istream; MyString::MyString(): str(nullptr), size(0) { #ifndef NDEBUG cout << "无参构造" << endl; #endif } MyString::MyString(const char *s): str(nullptr), size(0) { #ifndef NDEBUG cout << "构造函数" << endl; #endif // 申请空间 str = new char[strlen(s) + 1]; // 拷贝字符串 strcpy(this->str, s); size = strlen(str); } MyString::MyString(const MyString &s) { #ifndef NDEBUG cout << "拷贝构造" << endl; #endif // 申请空间 str = new char[s.size+1] ; // 拷贝字符串 strcpy(str, s.str); size = s.size; } MyString::~MyString() { #ifndef NDEBUG cout << "析构函数" << endl; #endif // 释放空间 if (nullptr != str) { delete [] str; str = nullptr; } } int MyString::Size() const { return size; } ostream &operator<<(ostream &out, MyString &str) { out << str.str; return out; } istream &operator>>(istream &in, MyString &str) { // 清除原空间 if (nullptr != str.str) { delete [] str.str; str.str = nullptr; } // 获取键盘输入 char buf[1024] = ""; in >> buf; str.str = new char[strlen(buf)+1]; strcpy(str.str, buf); str.size = strlen(buf); return in; } MyString MyString::operator+(const MyString &s) { // 返回新类型 int new_size = size + s.size; // 申请空间 char *new_str = new char[new_size+1]; strcpy(new_str, str); strcat(new_str, s.str); // 需要释放内存 MyString new_string(new_str); delete[] new_str; // 不必处理 // new_str = nullptr; return new_string; } MyString MyString::operator+(const char *st) { int new_size = size + static_cast<int>(strlen(st)); char *new_str = new char[new_size + 1]; strcpy(new_str, str); strcat(new_str, st); MyString new_string(new_str); delete[] new_str; // 确保不会使用, 不必处理 // new_str = nullptr; return new_string; } char &MyString::operator[](const int index) { if (index < 0 || index >= size) { cerr << "索引无效" << endl; return str[0]; } return str[index]; } // 这部份有问题? warning bool MyString::operator==(const MyString &s) const { return strcmp(str, s.str) == 0; } bool MyString::operator==(const char *st) const { return strcmp(str, st) == 0; } bool MyString::operator!=(const MyString &s) const { return strcmp(str, s.str) != 0; } bool MyString::operator!=(const char *st) const { return strcmp(str, st) != 0; } MyString &MyString::operator=(const MyString &s) { // 自赋值 if (&s == this) { return *this; } if (str == s.str) { return *this; } // 释放空间 if (nullptr != str) { delete [] str; str = nullptr; } size = s.size; str = new char[size+1]; strcpy(str, s.str); return *this; } MyString &MyString::operator=(const char *st) { // 错误自赋值 if (str == st) { return *this; } if (nullptr != str) { delete [] str; str = nullptr; } size = strlen(st); str = new char[size+1]; strcpy(str, st); return *this; }
测试用例:
#include "MyString.h" #include <iostream> using namespace std; int main() { MyString str1("mystring1"); MyString str2("mystring2"); cout << "str1 : " << str1 << endl; cout << "str2 : " << str2 << endl; auto str3 = str1 + "," + str2; cout << "str3 = str1 + ',' + str2 : " << str3 << endl; auto str4 = str3; cout << "str3 = str4" << endl; bool isSame = str3 == str4; cout << "str3 == str4 : " << isSame << endl; isSame = str3 == "mystring1,mystring2"; cout << "str3 == str1+','+str2 : " << isSame << endl; isSame = str3 != str4; cout << "str3 != str4 : " << isSame << endl; isSame = str3 != "mystring1,mystring2"; cout << "str3 != str1+','+str2 : " << isSame << endl; cout << "str3.size : " << str3.Size() << endl; cout << "str4 = str2" << endl; str4 = str2; cout << (str4 == str2) << endl; MyString str5(str4); cout << "str5: " << str5 << endl; cout << "Please input a string: "; cin >> str5; cout << "You input the string: " << str5 << endl; cout << "第一个字母: " << str5[0] << endl; cout << "修改第一个字母为A: "; str5[0] = 'A'; cout << str5 << endl; // 置换顺序 isSame = "Askdjf" == str5; cout << isSame << endl; return 0; }
结果:
构造函数 构造函数 str1 : mystring1 str2 : mystring2 构造函数 构造函数 析构函数 str3 = str1 + ',' + str2 : mystring1,mystring2 拷贝构造 str3 = str4 str3 == str4 : 1 str3 == str1+','+str2 : 1 str3 != str4 : 0 str3 != str1+','+str2 : 0 str3.size : 19 str4 = str2 1 拷贝构造 str5: mystring2 Please input a string: Askdjf You input the string: Askdjf 第一个字母: A 修改第一个字母为A: Askdjf 1 析构函数 析构函数 析构函数 析构函数 析构函数 Process finished with exit code 0
总结
主要工作是在运算符重载上面,重载过程中需要注意保持运算符原有的行为。
这些都不难:
+
实现两个字符串的相加,而返回另外一个字符串,不会修改两个加数。但设计当中需要注意释放内部在堆区申请的空间,为了确保空间能够正确释放,因而不能直接返回一个匿名
MyString
类,否则新字符串无法操作释放==
需要保证左右两边顺序互换都可以使用,而内部数据是不需要修改的,因而需要设置形参为const
。a == b
,如果b
是const
,那么a
也应该是const
,因此==
的重载需要使用const
修饰,确保匹配语义清晰。