一. 变量和基本类型
1. 基本内置类型
①算术类型
算术类型分为两类:整型和浮点型
带符号类型和无符号类型:带符号型可表示正数,负数,0;无符号型仅能表示大于等于0的值。在原类型前加unsigned即可表示无符号型,如unsigned int等。
注释包含//单行注释,/**/多行注释
②类型转换(P33)
布尔只有true,false(0false,非0true)
高精度赋值给低精度会丢失精度
勿混用有符号数和无符号数(有符号会转化成无符号,产生难以预料的结果)
③字面值常量
形如:21,3.14(指数部分用E或e表示),'a',"abc"(实际是常量字符构成的数组,结尾加空字符'\0’),true(false),nullptr(指针字面值)
当两个字符串字面值类型仅由空格,缩进,换行分隔时实际上是一个整体,可换行书写
cout<<"sda"
"dasdsa"<<endl;
2. 变量
变量提供一个具名的丶可供程序操作的存储空间。每个变量都有其数据类型。
①变量定义
变量定义基本形式是:类型说明符 一个或多个变量名组成的列表,其中变量名以逗号分隔,以分号结束。定义时可为其赋初始值。
注意:初始化是创建变量时赋予其一个初始值,而赋值是将对象当前值擦除,而以一个新值来替代。
C++11列表初始化:第2,3种带{}
int a=0;
int a={0};
int a{0};
int a(0);
默认初始化:若定义变量是未初始化,则默认初始化。任何函数体外的默认初始化为0, 类则由其自身决定(默认构造函数)。定义在函数体内部的内置类型将不被初始化,是一个未定义的值。
②变量声明和定义的关系
C++支持分离式编译机制,该机制允许将程序分为多个文件,每个文件可被独立编译,从而实现将程序拆分成多个逻辑部分来写。
将程序分为多个文件,则需在文件间有共享代码的方法。
声明使得名字为程序所知,一个文件想要使用别处定义的名字必须包含对那个名字的声明(变量名前加extern,且不显示初始化它)。而定义负责创建与名字关联的实体。
变量只能被定义一次,但能多次被声明。
extern int a;//声明
int b;//定义
extern int c=0;//定义
c++是静态类型语言,在编译阶段检查类型。
③标识符
cpp标识符由字母丶数字丶下划线组成,且必须以字母或下划线开头。
关键字:保留字。
④名字的作用域
作用域是程序的一部分,C++中大多数作用域以{}分割。名字的有效域始于名字的声明,结束于作用域的末端。
函数体外的为全局作用域(整个程序都可使用),内的为局部作用域。
3. 复合类型
复合类型是指基于其他类型定义的类型。一个基本的数据类型+一个声明符列表。
①引用(左值引用)
引用是为已存在的对象起了另一个名字(同一对象地址有两个名字)。&d形式,d是变量名。
为引用赋值,实际上赋给了与引用绑定的对象上。获取引用的值,实际上是获取与引用绑定对象的值。
int a=1;
int &b=a;
cout<<b<<endl;//1
b=2;
cout<<a<<' '<<b<<endl;//2 2
int &c;//错误,引用必须被初始化
int &d=1//必须是对象,且类型与引用类型符合。
②指针
指针是"指向"另外一种类型的复合类型,指针的值就是地址。指针本身就是一个对象,允许对指针赋值和拷贝,无需在定义时赋初值。
指针声明:*d,d是变量名
指针存放某个对象的地址,要想获取该地址,需要使用取地址符&
指针指向的对象的类型必须和他的匹配。
int v=42;
int *p=&v;//正确
int v2;
double *p1=&v2;//错误类型不匹配
p1=&v;//错误,把int形对象赋给double指针
利用指针访问对象:利用解引用符*来访问对象,对其赋值也就是对所指向的对象赋值
int v=42;
int *p=&v;//正确
cout<<*p<<endl;//42
cout<<p<<endl;//指针值是地址:0x62fe14
*p=0;//访问该地址的对象,并赋值
cout<<*p<<endl;//0
空指针:不指向任何对象。下面三种生成空指针的方法。
int *p1= nullptr;//c11用的方法
int *p2=0;
int *p3=NULL;//赋预处理变量NULL,等价于赋0
int zero=0;
p3=zero;//错误的,即使zero值为0也不行
赋值和指针:指针和引用都能间接对对象访问,但是引用并非是一个对象。一旦定义了引用,就无法令其再绑定到另外的对象。
而指针和存放的地址就没有如此限制,可以指向一个新的对象。
int i=42;
int ival=1;
int *p1=0;//初始化为空指针
int *p2=&i;//p2存着i的地址
int *p3;//若p3在块内,则值不确定
p3=p2;//p2,p3指向同一个对象i
p2=0;//p2为空指针
p1=&ival;//p1指向ival
*p1=0;//ival值为0,但是p1指向没有变
其他指针操作:只要指针拥有合法值,就能用在条件表达式中。
int ival=1024;
int *p1=0;//空指针
int *p2=&ival;//存放ival的地址
if(p1){}//p1值为0,false
if(p2){}//p2指向ival,值非0,true
//任何非0指针对应的条件的值都为true
//两个合法的相同类型的指针,可以用==和!=比较,结果为布尔。若他们存放的地址相同则结果为true
void*指针:特殊类型指针,可用于存放任意对象地址。
③理解复合类型的声明
一条定义语句可定义出不同类型的变量
//x是整型, y是int型指针,z是int型引用
int x=1, *y=&x, &z=x;
指向指针的指针:指针是内存中的对象,像其他对象一样也有自己的地址,因此允许把指针的地址在存到指针中。通过*的个数可以区别指针的级别,**表示指向指针的指针,***表示指向指针的指针的指针。
int iv=1024;
int *p1=&iv;//p1指向一个int型数
int **p2=&p1;//p2指向一个int型指针
//p2-->p1-->iv(1024)
//接引指针会得到指向的数,解引用指向指针的指针会得到一个指针
cout<<&iv<<endl;//0x62fe14,p2存的p1的地址,p1的值为iv的地址
cout<<iv<<' '<<*p1<<' '<<' '<<*p2<<' '<<**p2<<endl;
//1024 1024 0x62fe14 1024
指向指针的引用:引用本身不是一个对象,因此不能定义指向引用的指针。但是指针是对象,所以存在对指针的引用。
int i=42;
int *p;//p是int型指针
int *&r=p;//r是一个对指针p的引用
r=&i;//r引用了一个指针,因此给r赋值&i就是令p指向i
*r=0;//解引用r的到i,也就是p指向的对象,将i的值改为了0
/**
* 要理解r的类型到底是什么,从右向左阅读r的含义。离变量名最近的符号对变量类型有最直接的影响,
* 因此r是一个引用。声明符的其余部分用以确定r引用的类型是什么,此例中*说明r引用的是一个指针。
* 最后,声明的基本数据类型部分指出r引用的是一个int指针。
*/
4. const限定符
希望定义一种值不能被改变的变量。可用关键字const对变量类型加以限定。因为const对象一经创建后其值就不能再改变,所以const对象必须初始化。
默认const对象仅在文件内有效,编译器在编译过程中把用到该变量的地方替换成对应的值。
const int count=1;//编译时初始化
count=0;//错误,不能改写const对象
//初始化
const int i=get_size();//运行时初始化
count int j;//错误,j是一个未经初始化的常量
①const的引用
可以把引用绑定在const上,但是不能修改其绑定的对象。
引用的类型必须与其所引用的对象类型一致,但是初始化常量引用时允许用任何表达式作为初始值,只要该表达式的结果能够转化成引用的类型即可。
int i=42;
const int &r1=i;//✔
const int &r2=2;//✔,常量引用
const int &r3=r1*2;//✔,常量引用
int &r4=r1*2;//✖,r4是一个普通的非常量引用
r1=0;//错误,r1是常量引用不允许修改。
i=0;//但是i非常量可以修改
double v1=3.14;
const int &r5=v1;
/**
* 编译器将上述代码变成了
* const int temp=v1;//由双精度数生成一个临时的整型常量
* const int &r5=temp;//让r5绑定这个临时量
* 所谓临时量就是编译器需要一个空间暂存表达式求值的结果
*/
②指针和const
指向常量的指针:可以令指针指向常量,要想存放常量对象的地址,只能使用指向常量的指针。不能通过该指针改变对象的值.
指针的类型必须与其所引用的对象类型一致,但是允许例外:允许一个指向常量的指针指向一个非常量对象
const double pi=3.14;
double *p1=π//错误,p1是一个普通指针
const double *p2=π//✔,p2是指向常量的指针
*p2=2;//错误,不能改变常量
double dv=3.2;
p2=&dv;//允许指向常量的指针指向非常量对象,但不能通过p2改变dv(即指向的值)
//,允许p2的值改变
常量指针:允许把指针本身定义为常量。常量指针必须初始化,初始化完成后,其值(存放在指针中的地址不允许改变了)。
/**
* 从右往左看声明符,离变量名最近的就是其类型
*/
int errNum=0;
int *const curErr=&errNum;//curErr将一直指向errNum
const double pi=3.14;
const double *const pip=π//pip是一个指向常量对象的常量指针
/**
* 指针本身是一个常量不意味着不能通过指针修改其所指对象的值,能否这样做依赖
* 于所指对象的类型
* pip自己和指向对象都不行
* curErr指向对象可以改
*/
*pip=2.2;//错误,pip是一个指向常量的指针
*curErr=0;//正确
③顶层const
指针本身是一个对象,它又可以指向另一个对象。指针本身是不是常量以及指针所指的是不是一个常量是两个独立的问题。
用名词顶层const表示指针本身是一个常量,底层const表示指针所指的对象是常量。
int i=0;
int *const p1=&i;//顶层const,不能改变p1的值
const int ci=42;//顶层const,不能改变ci的值
const int *p2=&ci;//底层const,允许改变p2的值
const int *const p3=p2;//靠右的是顶层const,靠左的是底层const
const int &r=ci;//用于声明引用的const都是底层const
/**
* 当执行对象拷贝时,顶层const不受影响(地址,仅多了指向该地址的指针罢了),底层const有限制
* 因为将地址给一个非常量指针,能通过这个指针对底层const所指对象修改,需要限制,必须也是
* 指向常量的指针
*
* 当执行对象拷贝时,拷入和拷出对象必须有相同的底层const资格,或者两个对象的数据类型能转换。
* 一般,非常量可以转换成常量,反之不行。
*/
i=ci;//✔,ci是顶层const
int *p=p3;//✖,p3包含底层const,而p没有
p2=p3;//✔,p2,p3都是底层const
p2=&i;//✔,int*可传换const int*
int &r=ci;//✖,普通引用不能绑定到int常量上
const int &r2=i;//✔,const int&可以绑定到普通int上
④constexpr和常量表达式
常量表达式:是指值不会改变并且在编译过程中就能得到计算结果的表达式。一个对象(或表达式)是不是常量表达式由其数据类型和初始值共同决定。
const int maxc=20;//maxc是常量表达式
const int limit=maxc+1;//limit是常量表达式
int c=1;//c不是,虽然1是字面值常量,但其数据类型是int
const int sz=get_size();//sz不是,虽然其本身是常量,但是其值运行时才能知道
constexpr: C++11规定,允许将变量声明为constexpr类型以便由编译器验证变量的值是否为常量表达式。声明为constexpr的变量一定是一个常量且必须由常量表达式初始化。constexpr所引用的对象必须在编译期就决定地址。
constexpr int mf=20;//20是常量表达式,字面值
constexpr int li=mf+1;//li是常量表达式
constexpr int sz=getSize();//只有getSize()是constexpr函数时才是常量表达式
指针和constexpr:若在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关。
const int *p= nullptr;//p是一个指向整型常量的指针,底层const
constexpr int *q= nullptr;//q是一个指向整数的常量指针,顶层const
5. 处理类型
①类型别名
类型别名:一个名字,某种类型的同义词。传统关键字typedef,新别名声明关键字using。
/**
* 传统typedef
* 关键字typedef作为声明语句中的基本数据类型的一部分
* 这里的声明符也可以包含类型修饰,从而构造复合类型
*/
typedef double wages;//wages是double的同义词
typedef wages base, *p;//base是double的同义词,p是double*的同义词
/**
* 别名声明:using
*/
using SI=Sales_item;//SI是Sales_item的同义词
wages week;//等价于double week
SI item;//等价于Sales_item item
指针丶常量和类型的别名
//基本数据类型+声明符
typedef char *pstring;
const pstring cstr= 0;//cstr是指向char的常量指针
const pstring *ps;//ps是一个指针,它的对象时指向char的常量指针
/**
* 上面两条语句的基本数据类型都是const pstring,基本数据类型是指针.
* const是对给定类型的修饰,pstring实际上是指向char的指针
* 因此,const pstring就是指向char的常量指针,而非指向常量字符的指针
*/
/**
* 遇到一条使用了类型别名的声明语句时,人们会错误的把类型别名替换成它本来的样子
*/
const char *cstr=0;//是对const pstring cstr的错误理解
//这里基本数据类型是const char,而*变成了声明符一部分
②auto类型说明符
C++11引入auto,使其让编译器分析表达式所属类型。
/**
* auto声明多个变量时,基本数据类型应一致
*/
auto i=0, *p=&i;//✔,i是整数,p是整型指针
auto sz=0, pi=3.14;//错误,sz和pi的类型不一致
复合类型丶常量和auto:编译器推断出来的类型有时和初始值类型不完全一样,编译器会适当改变结果类型使其符合初始化规则。
// r是i的别名,i是整数。当引用被用作
// 初始值时,实际参与初始化的是引用对象的值
int i=0, &r=i;
auto a=r;//a是一个整数
/**
* auto通常会忽视顶层const,底层const会保留下来
*/
const int ci=i, &cr=ci;
auto b=ci;//b是一个整数(ci的顶层const被忽略了)
auto c=cr;//c是一个整数(cr是ci的别名,ci是顶层const)
auto d=&i;//d是整型指针(整数的地址就是指向整数的指针)
auto e=&ci;//e是指向整数常量的指针(对常量对象取地址是一种底层const)
//若想要推断出的auto类型是一个顶层const,需明确指出
const auto f=ci;//f是const int
//还可以将引用的类型设置为auto,原来的初始化规则仍然适用
//设置一个类型为auto的引用时,初始值中的 顶层const 仍然保留
auto &g=ci;//g是整型常量引用。绑定到ci
auto &h=42;//✖,不能为非常量引用绑定字面值
const auto &j=42;//✔,常量引用可绑定字面值
③decltype类型指示符
希望从表达式的类型推断出要定义的变量类型,但不想用该表达式的值初始化变量,因此引入decltype,其作用是选择并返回操作数的数据类型。编译器分析表达式并得到他的类型,却不实际计算表达式的值。
/**
* sum的类型就是函数sum()返回类型
* 编译器并不实际调用sum()函数
*/
decltype(sum()) sum=1;
/**
* decltype处理顶层const和引用方式与auto不同
* 若decltype使用的表达式是一个变量,则decltype返回该变量的类型(包含顶层const和引用在内)
*/
const int ci=0, &cj=ci;
decltype(ci) x=0;//x是const int类型
decltype(cj) y=x;//y的类型是const int&,y绑定到x
decltype(cj) z;//✖,z是一个引用,必须初始化
/**
* 若decltype使用的表达式不是一个变量,则decltype返回表达式结果对应的类型
*
* r是一个引用,所以decltype(r)结果是一个引用类型。若想是r所指的对象的类型
* 则将r作为表达式的一部分:r+0
*
* 若表达式内容是解引用操作,则decltype将得到引用类型。解引用指针可以得到
* 指针所指的对象,还能给其赋值,因此decltype(*p)结果是int&而非int
*/
int i=42, *p=&i, &r=i;
decltype(r+0) b;//✔,b是int
decltype(*p) c;//✖,c是int&必须初始化
/**
* 变量名加不加()有很大不同
* 加上(),编译器会把其当成一个表达式。变量是一种可作为
* 赋值语句左值的特殊表达式,所以得到引用类型
*/
decltype((i)) d;//d是int&,必须初始化
decltype(i) e;//e是int
6. 自定义数据结构
①struct
struct DateStructName {
char id='a';//file
int a={0};
};
struct 类名{
类体};
struct Name{
} a, *b;//不建议混用对象定义和类定义
Name c, *d;
DateStructName a;
a.id='0';
类体定义类的成员,即数据成员。
可为数据成员提供一个类内初始值。没有初始值的成员将默认初始化。=号或{}内,不能()内。
②编写自己的头文件
可以在函数体内定义类,但一般不。在函数体外定义类时,各个指定的源文件可能只有一处该类的定义,且不同文件使用同一个类,类的定义需要保持一致。
为确保各个文件类的定义一致,类通常被定义在头文件中,且类所在的头文件的名字应与类的名字一样。比如,库类型string在名为string的头文件中定义。所以我们应该将DateStructName 类定义在名为DateStructName.h的头文件中。
头文件通常包含那些只能被定义一次的实体,如类丶const丶constexpr变量等。
头文件一旦改变,相关源文件必须重新编译以获取更新过的声明。
同一头文件可能被多次包含,显式和隐式(隐式就是包含的头文件B.h也含有string.h,而自身含有string.h是显式)
#include <iostream>//标准库头文件
#include "DateStructName.h"//非标准库文件
using namespace std;
signed main()
{
DateStructName a;
a.id='0';
}
预处理器:确保多次包含仍能安全工作的技术。预处理器是在编译前执行的一段程序,可以部分改变我们所写的程序。比如预处理功能#include,当预处理其看到#include标记时就会用指定的头文件内容代替#include。
头文件保护符:头文件保护符也是一项预处理功能,依赖于预处理变量(如NULL作为空指针,预处理变量无视c++作用域规则,由预处理器处理,在编译器之前)。预处理变量有已定义和未定义两种状态。#define指令把一个名字设定为预处理变量,另外两个指令则分别检查某个指定的预处理变量是否已经定义:#ifdef当且仅变量已定义时为真,#ifndef当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直至遇到#endif。使用上述功能可防止重复包含。
一个
#endif
只能与一个对应的(之前离自己最近的,类似{})#if
、#ifdef
或#ifndef
匹配。每个条件编译块必须正确地关闭和匹配。一个#开和,一个#关匹配
//
// Created by 明 on 2024/5/4.
// 头文件 DateStructName.h
//
#ifndef UNTITLED_STRUCT_DATESTRUCTNAME_H
#define UNTITLED_STRUCT_DATESTRUCTNAME_H
struct DateStructName {
char id;//file
};
#endif //UNTITLED_STRUCT_DATESTRUCTNAME_H
/**
* 另一个文件main.cpp第一次遇到DateStructName.h时,ifndef的检查结果为真
* 预处理将继续执行后面的代码直到遇到#endif,此时预处理变量UNTITLED_STRUCT_DATESTRUCTNAME_H
* 已经定义, DateStructName.h也被拷贝到main.cpp
*
* 若后面main.cpp再次遇到DateStructName.h,#ifnedf检查结果为假,编译器将忽略#ifndef
* 到#endif之间的部分
*/
二. 字符串丶向量和数组
1. 命名空间的using声明
方便引入。
头文化中不应包含using声明,因为头文件会被引入别的程序,可能造成命名空间冲突。
#include <iostream>
/**
* using namespace::引入的名字
* 域作用符::含义:编译器从操作符左侧名字所示的作用域
* 中寻找右侧的名字
*/
using std::cin;
signed main()
{
int i;
cin>>i;//✔,等价于std::cin
cout<<i;//✖,没有声明必须用完整名字
std::cout<<i;//✔
}
2. 标准库类型string
标准库类型string表示可变长的字符序列,使用string类型必须首先包含string头文件。作为标准库的一部分,string定义在命名空间std中,必须包含下述代码:
#include <string>
using std::string;
①定义和初始化string对象
直接初始化和拷贝初始化:若使用等号初始化一个变量,实际执行的是拷贝初始化,编译器把等号右侧的初始值拷贝到新创建对象,若不使用等号,则执行的是直接初始化。
string s5="a";//拷贝初始化
string s6("a");//直接初始化
string s7(2, 'c');//直接初始化 cc
string s8=string(2, 'c');//拷贝初始化,string对象拷贝给s8
②string对象上的操作
读写string对象
/**
* 执行读取操作时,string对象会自动忽略开头, 结尾空白
* (空格符丶换行符丶制表符)
*/
string s;
cin>>s;//" hello "
cout<<s<<endl;//"hello"
/**
* 和内置类型的输入输出一样,string对象此类操作也将返回运算符
* 左侧的运算对象为结果,因此多个输入和输出可以连在一起
*/
string s1;
cin>>s>>s1;//" hello world "
cout<<s<<s1<<endl;//"helloworld"
//读取未知数量string对象
//每遇到文件结束标记和非法输入会一直读
string word;//一次读一个
while (cin>>word) cout<<word<<endl;
/**
* 使用getline读取一整行:
* 希望在最终得到的字符串保留输入时的空白符,用getline
* 函数代替>>运算符。getline两个参数是一个输入流和一个
* string对象,函数从给定输入流中读取内容,直到遇到换行
* 符(换行符也被读入了),然后将所读内容存到那个string对象
* 中(不存换行符).getline只要一遇到换行符就结束读取操作并
* 返回结果。若一开始是换行符,所得string是个空字符串
*
* getline也会返回它的流参数
*/
string line;//一次读一行
while(getline(cin, line)) cout<<line<<endl;
string::size_type类型:size()函数返回的是一个string::size_type类型。标准库类型都定义了几种配套的类型,这些类型体现了标准库类型与机器无关性。size_type就是一种,使用时通过域操作符表名是在类string中定义的。
string::size_type类型是一个无符号类型的值。在表达式中混用无符号和有符号会产生意想不到的结果。s.size()<n,若n是一个具有负值的int,则左侧表达式几乎为true,因为负值n会转化成一个比较大的无符号值。所以表达式中有size()函数就不要用int了。
两个string对象相加:将左右两部分拼成一个新对象。+=则是将右侧string对象追加到左侧对象
字面值和string对象相加:标准库允许把字符字面值和字符串字面值转化成string对象。当把string对象和字符字面值以及字符串字面值混在一条语句时,必须确保每个+两侧至少一个为string。为了与C兼容,c++中的字符串字面值并不是string类型
string s1="hello", s2="world";
string s3=s1+","+s2+'\n';
string s4="hello"+",";//✖
string s5=s1+","+"world";//✔,每个加法左右两侧有string,(s1+",")
string s6="hello"+","+s2;//✖,("hello"+",")
③处理string对象中的字符
使用基于范围的for处理每个字符:
string s1="hello";
/**
* 范围for遍历给定序列中每个元素并执行某种操作
* for(declaration:expression)
* statement
*
* expression是一个对象,declaration定义一个变量,用于访问
* 序列中的基础元素,每次迭代,declaration会被初始化为expression
* 的下一个值
*/
//输出每个字符
for(auto c:s1)
cout<<c<<endl;
/**
* 若想改变string字符值,需要把循环变量定义成引用形式
* 该引用变量被绑定到序列每个元素上
*
* 将s1都变成大写
*/
for(auto &c:s1)
c=toupper(c);
cout<<s1<<endl;//HELLO
3. 标准库类型vector
标准库类型vector表示对象的集合,其中所有对象的类型都相同。集合中每个对象都有一个与之对应的索引用于访问对象。
要想使用vector,必须进行以下声明:
#include <vector>
using std::vector;
c++有类模板和函数模板,vector就是类模板。模板本身不是类或函数,可以看成编译器生成类或函数编写的一份说明书。编译器根据模板创建类或函数的过程称为实例化,当使用模板时,需要指出编译器应把类或函数实例化为何种类型。
对于类模板来说,我们需要提供一些额外信息来指定模板到底实例化成什么类,需要哪些信息由模板决定。一般:在模板名字后面跟一对<>在内部放上信息。
vector能容纳绝大多数类型的对象作为元素,但是引用不是对象,所以不存在包含引用的vector。
vector<int> ivec;//ivec内部存放int类型对象
vector<Sales> svec;//svec存放Sales类型对象
vector<vector<string>> file;//file存放vector<string>对象
①定义和初始化vector对象
当使用{},但提供的值不能用来列表初始化,则用这样的值构造对象,例如string:
vector<string> v1{"hi"};//列表初始化,v1有一个元素
vector<string> v2("hi");//✖,不能用字符串字面值构建vector对象
vector<string> v5(10, "hi");//v5有10个hi元素
vector<string> v3{10};//v3有10个默认初始化元素
vector<string> v4{10, "hi"};//v4有10个值为hi的元素
值初始化:通常情况下,可只提供vector对象容纳的元素数量而略去初始值。此时库会创建一个值初始化的元素初值,并把它赋给容器中的所有元素。这个初值由vector中的元素类型决定。若为内置类型,比如int, 则初始值自动设为0。若为某种类型,比如string,则元素由类默认初始化:若类不支持默认初始化,必须指定初值。
②向vector对象中添加元素
vector成员函数push_back()。push_back()负责将值压入到vector尾端
c++标准要求vector能在运行时高效快速添加元素,所以最初不必指定其元素大小,指定了甚至会更差。
vector<int> v;
v.push_back(1);
③其他vector操作
计算vector内对象索引:下标从0开始,下标类型是size_type类型。
vector<int> v{2, 3};
for(auto i:v) cout<<i<<' ';//2 3
puts("");
for(auto &i:v) i*=i;
for (int i = 0; i < (int)v.size(); ++i)//下标访问
{
cout<<v[i]<<' ';//4 9
}
puts("");
v[1]=6;
cout<<v[1]<<endl;//6
v[2]=7;//✖,不能用下标添加元素。只能对已存在的元素执行下标操作.
//编译器不会发现,但是运行时会访问一个无法预知的值
4. 迭代器介绍(itearator)
①使用迭代器
可以通过迭代器对容器进行间接访问。
与指针不同,获取迭代器不是用取地址符,有迭代器的类型同时拥有返回迭代器的成员。即begin和end
//若容器为空,则begin和end都返回尾后迭代器
vector<int> v{2, 3};
auto b=v.begin();
auto e=v.end();//end返回指向容器尾元素的下一个位置的迭代器(尾后迭代器)
string s("some string");
if(s.begin()!=s.end())//s不为空
{
auto it=s.begin();
*it= toupper(*it);//将第一个字符改成大写
}
cout<<s<<endl;//Some string
//遍历容器
for(auto it=s.begin();it!=s.end();it++)
cout<<*it<<' ';//S o m e s t r i n g
迭代器类型:类似于size_type一样,我们无需知道迭代器精确类型。实际上,那些拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型。
vector<int>::iterator it;//it能读写vetcor<int>中的元素
string::iterator it2;//it2能读写string对象中的元素
vector<int>::const_iterator it3;//it3只能读不能写
/**
* const_iterator和常量指针类似,能读取但是不能修改它所指元素的值。
* iterator可读可写。
* 常量对象只能是第一个,非常量对象可1可2
*/
/**
* begin和end返回iterator还是const_iterator由对象是否是常量决定。
* 但是这种默认行为有时并非我们所要。
* c++11引入cbegin和cend用于返回常量迭代器
*/
vector<int> v;
const vector<int>cv;
auto i1=v.begin();//i1类型vector<int>::iterator
auto i2=cv.begin();//i2类型vector<int>::const_iterator
auto i3=v.cbegin();//i3类型vector<int>::const_iterator
结合解引用和成员访问的操作:解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员。(->运算符)
/**
* 对于一个字符串组成的vector,想要检查其元素是否为空
* (*it)中圆括号不可少,先解引用,在执行.运算符
* 若不加括号,则先.运算符,而it是迭代器
*/
vector<string> sv{"adas"};
auto it=sv.begin();
cout<<(*it).empty()<<endl;//0
cout<<it->empty()<<endl;//0,为简化上面
//c++定义->运算符,将解引用和成员访问结合起来
某些对vector对象的操作会使迭代器失效:不能在for循环中向vector对象添加元素。任何一种可能改变vector容量的操作(push_back),都会使vector对象的迭代器失效。
②迭代器运算
迭代器距离:只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一个位置,就能将其相减,所得结果是两个迭代器的距离,其类型是名为difference_type的带符号整型数。
vector<int> v{1, 2, 3};
auto mid=v.begin()+v.size()/2;//得到最接近v中间元素的迭代器
for(auto it=v.begin();it<mid;it++)//处理前半部分元素
cout<<"";
5. 数组
定义:数组是一种类似标准库类型vector的数据库,其也是存放类型相同的对象的容器,这些对象本身无名字,需要通过其位置来访问。数组的大小固定,因此性能较好,但是灵活性较差。
①定义和初始化内置数组
数组是一种复合类型。数组的声明形如a[d],其中a是数组的名字,d是数组的维度。维度说明了数组中元素的个数,因此必须大于0.数组中元素的个数也属于数组类型的一部分,编译的时候维度应该是已知的,维度必须是一个常量表达式。默认情况下,数组默认初始化(同内置类型)。
定义数组必须指明类型,不允许用auto。与vector相同,数组元素应为对象,因此不存在引用的数组。
unsigned cnt=42;//不是常量表达式
constexpr unsigned sz=42;//常量表达式
int a[10];//10个整数的数组
int *pa[10];//含有10个整型指针的数组
string bad[cnt];//✖, cnt不是常量表达式
string strs[get_size()];//当get_size()是constexpr时正确,否则错误
/**
* 可对数组列表初始化,此时允许忽略数组的维度。
* 若声明时没有指明维度,编译器会根据初始值的数量推测
* 若指明了维度,则不应超出维度。
* 若维度比初始值数量大,则用提供的初始值初始化靠前的元素,剩下
* 元素默认值
*/
const unsigned siz=3;
int a1[siz]={0, 1, 2};
int a2[]={0, 1, 2};//维度是3
int a3[5]={0, 1, 2};//等价于{0,1,2,0,0}
string a4[siz]={"a", "b"};//{"a", "b",""}
int a5[2]={0, 1, 2};//✖,初始值过多
/**
* 字符数组可以用字符串字面值对此类数组初始化。
* 当使用这种方式时,其末尾的空字符'\0'也会被拷贝到字符数组
*/
char s1[]={'c', '+', '+'};//无空字符
char s2[]={'c', '+', '\0'};//有显示空字符
char s3[]="c++";//自动添加表示字符串结束的空字符'\0'
const char s4[6]="123456"//✖,没有空间存储空字符
/**
* 不允许用一个数组初始化和赋值另一个数组
*/
/**
* 和vector一样,数组能存放大多数类型的对象。
* 可以定义一个存放指针的数组。
* 数组本身又是对象,允许定义数组的指针和数组的引用
*/
int *ptrs[10];//含有10整型指针的数组
int &refs[10];//✖,不存在引用的数组
int arr[10];
int (*Parray)[10]=&arr;//Parray指向一个含有10个整数的数组
int (&arrRef)[10]=arr;//arrRef引用一个含有10个整数的数组
/**
* 对于ptrs来说,从右向左理解,先知道定义了一个大小为10的数组,它的名字是ptrs,然后知道
* 数组中的元素是指向int的指针
*
* 理解数组,从名字开始,由内向外
* 对于Parray来说,由内向外理解(数组的维度紧跟被声明的名字,而'()'是名字),*Parray代表
* Parrar是个指针,观察右边,知道Parray是一个指向大小为10的数组的指针,最后看左边,知道
* 数组中的元素为int,因此其含义为,Parray是一个指针,它指向一个int数组,数组中包含10个元素
*
* 同理,arrRef是一个引用,它的引用对象是一个大小为10的数组,数组中元素类型是int
*/
int *(&arry)[10]=ptrs;//arry是数组的引用,该数组含有10个整型指针
②访问数组元素
数组的索引从0开始,到9结束
在使用数组下标时,通常定义为size_t类型。size_t是一种机器相关的无符号型,它被设计足够大以便能表示内存中任意对象的大小,定义在cstddef头文件中
访问类似于vector
③指针和数组
在C++中,在使用数组时编译器一般会把它转化成指针。通常使用取地址符来获取某个对象的指针,数组元素也是对象,因此可对数组的元素使用&。
在很多用到数组名字的地方,编译器会自动将其替换为一个指向数组首元素的指针。在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。
string nums[]={"one", "two"};
string *p=&nums[0];//p指向nums的第一个元素
string *p2=nums;//等价于p2=&nums[0]
int ia[]={0, 1, 2};
auto ia2(ia);//ia2是一个指针,指向ia第一个元素
auto ia3(&ia[0]);//ia3是指针
//但是使用decltype关键字时,上述转化不会发生,
//decltype(ia)返回的类型是由10个整数构成的数组
decltype(ia) ia4={0,1 ,2};
ia4=p;//✖,不能用整型指针给数组赋值
ia4[4]=i;//✔,给ia4的一个元素赋值
指针也是迭代器:指向数组元素的指针拥有更多功能。vector和string的迭代器支持的运算,数组的指针全都支持。
int arr[]={0, 1,2, 3};
int *p=arr;//p指向arr第一个元素
p++;//p指向arr第二个元素
int *s=arr, *e=&arr[4];//分别指向arr首元素和尾元素的下一个位置
for(auto i=s;i!=e;i++)//遍历arr所有元素
cout<<*i<<' ';
puts("");
标准库函数begin和end:用于更安全简单的得到数组首尾指针, 这两个函数定义在iterator头文件中。
int a[]={1, 2, 3};
int *s= begin(a);//首元素指针
int *e= end(a);//得到尾元素下一个元素的指针
指针运算:和迭代器中标准容器运算,string(vector)容器运算相同。运算结果仍为指针。
int a[]={1, 2, 3, 4, 5};
int *ip=a;//等价于int *ip=&a[0]
int *ip2=ip+4;//ip2指向a的为元素a[4]
auto n=end(a)- begin(a);
/**
* n值为5,两个指针相减结果是ptrdiff_t的标准库类型
* 差值可能为负,所以其是带符号类型
*/
解引用和指针运算的交互:指针加上一个整数所得结果仍为指针,假设结果指针指向了一个元素,则允许解引用该结果指针。
int a[]={0, 1, 2, 3, 5};
int last=*(a+4);//将last初始化为5,即a[4]
last=*a+4;//✔,lase=4等价于a[0]+4
下标和指针:大多数情况对数组的操作,即转化为对指针的操作
int a[]={0, 1, 2, 3, 5};
int i=a[2];//a转化为指向数组首元素的指针,a[2]得到(a+2)所指的元素
int *p=a;//p指向a的首元素
i=*(p+2);//等价于i=a[2]
//只要指针指向的是数组中的元素(或尾后元素),都可以执行下标运算
p=&a[2];//p指向索引为2的元素,p即p[0]
int j=p[1];//p[1]等价于*(p+1),即a[3]所指元素
int k=p[-2];//即a[0]表示的那个元素
/**
* 标准款类型下标必须为无符号类型
* 而内置下标运算无此要求
*/
④C风格字符串(字符数组)
尽管C++支持C风格字符串,但是最好不要使用。因为其使用不便,且容易引发安全漏洞。
C风格字符串不是一种类型,而是一种表达和使用字符串约定俗成的写法。按此习惯书写的字符串存放在字符数组中并以空字符结尾('\0')
比较字符串:比较string对象时,用的是普通关系运算符。若把这些运算符用在C风格字符串,实际比较的将是指针而非字符串本身。目标字符串的大小由调用者指定
string s1="abc";
string s2="cba";
if(s1>s2) puts("");
char s3[]="asdsa";
char s4[]="das";
if(s3<s4) puts("");//✖,试图比较两个无关地址
/**
* 向对于C风格字符串的使用,需要计算大小,检查内存,这样的代码
* 到处都是,程序员无法兼顾
* 容易造成安全问题
*/
⑤与旧代码的接口
在C++标准库前已经有很多C++程序了,他们并没有使用string,vector。为了使更方便使用旧风格代码,C++专门提供一组功能。
现代C++程序应当尽量使用vector和迭代器,避免使用内置数组和指针;应该尽量使用string,避免使用C风格字符串。因为指针常用于底层操作,易引发一些细节错误,且不安全。
混用string对象和C风格字符串
/**
* 任何出现字符串字面值的地方都可以用以空字符结尾的字符数组替代:
* 允许以空字符结束的字符数组来初始化string对象,或为其赋值
* 在string对象的加法运算中允许使用以空字符结尾的字符数组作为
* 其中一个运算对象(不能两个都是)
* 在string对象的复合赋值运算中允许以空字符结束的字符数组作为右侧运算对象
*
* 上述性质反过来不成立
*/
string s("hello");
char s2[]={'w', 'o', 'r', 'd', '\0'};
string s3(s2), s4=s2;
s=s2;
s=s+s2;
s+=s2;
char *str=s;//✖,不能用string对象初始化char*
const char *str2=s.c_str();//✔,c_str函数返回一个C风格字符串,即指针
利用数组初始化vector对象:不允许使用一个数组为另一个内置类型的数组赋值,不允许使用vector对象初始化数组,但允许数组初始化vector对象,只需指明拷贝区域的首元素地址和尾后地址即可。
int a[]={0, 1, 2};
vector<int> v(begin(a), end(a));//v中是a中元素的副本
vector<int> v2(a+1, a+3);//a[1], a[2],拷贝部分元素
6. 多维数组
严格来说,C++中并没有多维数组,通常所说的多维数组是数组的数组。
//由内而外理解
int a[3][4];//大小为3的数组,每个元素是含有4个整数的数组
int b[10][20][30]={0};//大小为10的数组,它的每个元素是大小为20的数组,
//这些数组的元素是含有30个整数的数组,并初始化为0
/**
* 初始化
*/
int a1[3][4]={//3个元素,每个元素都是大小为4的数组
{0, 1, 2, 3},
{4, 5, 6, 7},
{8, 9, 10, 11}
};
//等价于上面
int a2[3][4]={0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
//初始化每一行第一个元素, 未列出元素默认初始化
int b1[3][4]={{0}, {4}, {8} };
int b2[3][4]={0, 3, 6, 9};//初始化第一行4个元素
多维数组的下标引用
数组每个维度对应一个下标运算符,若表达式含有的下标运算符和数组维度一样多,该表达式结果是给定类型的元素;若表达式含有的下标运算符数量比数组的维度小,则表达式将给定索引处的一个内层数组。
int arr[9][9][9], a[3][4];
a[2][3]=arr[0][0][0];//用arr首元素为a的最后一行最后一列赋值
int (&row)[4]=a[1];//将row(含有4个整数的数组的引用)绑定到a的第二个四元数组上
for(int i=0;i<4;i++)//访问每一行
for(int j=0;j<3;j++)//行内列
a[i][j]=0;//每个元素初始化为0
for(auto &row:a)//row是int &[4]
for(auto &col:row)//col是int&
col=0;//同上
/**
* 下面循环没有任何写操作,但是仍然将外层声明了引用
* 这是为了避免数组被自动转为指针
*/
for(auto &r:a)
for(auto c:r)
cout<<c;
/**
* 下面错误。
* 因为r不是引用,而是变成指向其 首元素的指针
* c将对int*内遍历,是错误的
*/
for(auto r:a)
for(auto c:r)
cout<<c;
指针和多维数组:当程序使用多维数组的名字时,也会自动将其转成指向数组首元素的指针。
定义指向多维数组的指针时,要注意多维数组实际上是数组的数组,所以由多维数组名转化得来的指针实际上是指向第一个内层数组的指针(首元素就是一个数组)。
int a[3][4];
int (*p)[4]=a;//指向含有4个整数的数组
p=&a[2];//指向a的尾元素(数组)
//使用auto,不必关心类型
for(auto p= begin(a);p!=end(a);p++)//p(int *[4])指向a的第一个数组
for(auto q= begin(*p);q!=end(*p);q++)//q(int *)指向内存数组的首元素, *p解引用访问该数组
cout<<*q<<' ';//访问元素
/**
* 可使用别名简化多维数组的指针
*/
using array=int[4];//4个整数组成的数组
//typedef int array[4];等价于上面
for(array *p=a;p!=a+3;p++)
for(int *q=*p;q!=*p+4;q++)
cout<<*q<<' ';
三. 表达式
1. 基础
①基本概念
C++定义了一元运算符和二元运算符。作用于一个运算对象的运算符是一元运算符(&,解引用*);作用于两个运算对象的运算符是二元运算符(==和乘法*)。还有一个作用于三个运算对象的三元运算符。函数调用也是一种特殊的运算符,对运算对象的数量没有限制。
组合运算符和运算对象:对于含有多个运算符的复杂表达式来说,要想理解它的含义首先要理解运算符的优先级丶结合律以及运算对象的求值顺序。
运算对象转换:在表达式求值的过程中,运算对象常由一种类型转化成另外一种类(即使运算对象的类型不同,但只要能转换即可)(整数->浮点数,浮点数->整数)(小整数bool,char,short会被提升成大整数int)。
重载运算符:C++定义了运算符作用于内置类型和复合类型的运算对象时所执行的操作。当运算符作用于类类型运算对象时,用户可以自行定义其含义,称之为重载运算符(IO库的>>,<<以及string,vector对象和迭代器使用的运算符都是重载运算符)(使用重载运算符时,运算对象的类型和返回值的类型。都是由该运算符定义的;但是运算对象的个数丶运算符的优先级和结合律都是无法改变的)。
左值和右值:C++表达式要不然是右值,要不然是左值(左值可以位于赋值语句的左侧,右值则不能)。当一个对象被用作右值时,用的是对象的值(内容);当对象被用作左值时,用的是对象的身份(在内存中的位置)。左值是指向内存区域的对象,左值可以出现赋值表达式的左边或右边,当左值出现的右边时,自动转换为右值使用。右值是指存储在内存中的数值本身,右值不能出现的赋值表达式左边,否则编译出错。(n = m; // 合法,n是左值,m自动转换为右值)
赋值运算符需要一个(非常量)左值作为其左侧运算对象,得到的结果仍为一个左值。
取地址符作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值
②优先级与结合律
复合表达式:含有两个或多个运算符的表达式。优先级和结合律决定了运算对象的组合方式。高优先级运算符比低优先级运算符的运算对象更为紧密的组合。若优先级相同,则有结合律决定(从左至右)。括号无视上述规则。
③求值顺序
优先级规定了运算对象的组合方式,但是没说明运算符按照什么顺序求值,大多数情况下不会指定求值顺序。
对于没有指定执行顺序的运算符,若表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。
int i=f1()*f2();//f1,f2一定会在乘法前调用,但是不知道调用前后顺序
cout<<i<<' '<<++i<<endl;//未定义的, 表达式结果未知
/**
* 有四种运算符明确规定了运算对象的求值顺序
* &&:先求左侧,只有当做测为真时才求右侧
* || ?: ,
*/
/**
* 运算对象的求值顺序和优先级丶结合律无关
* 优先级规定:g的返回值和h的返回值相乘
* 结合律规定:f的返回值与g和h的乘积相加,所得结果再和j的返回值相加
* 对于这些函数的调用顺序无明确规定
*
* f,g,h,j是无关函数,则函数调用顺序不受限制。反之若某个函数影响同一对象
* 则它是一条错误的表达式,将产生未定义行为
*/
f()+g()*h()+j
/**
* 两个准则:
* 1. 可用括号强制使表达式符合逻辑要求
* 2. 若改变了某个运算对象的值,在表达式的其他地方不要再使用这个运算对象。(列外:
* 当改变运算对象的子表达式本身就是另外一个子表达式的运算对象时该规则无效)
* 在表达式*++iter中,递增运算符改变iter的值,iter(已改变)的值又是解引用运算符
* 的对象。此时,求值顺序不会成为问题。
*/
string s="das";
for(auto it=s.begin();it!=s.end();++it)
*it= toupper(*it);//当前字符改成大写
//在上述程序中我们吧解引用和,递增分开完成
/**
* 下面看似等价,实际错误。在一条语句中有两个部分
* 用到beg,但是有一个对其修改了
*/
auto beg=s.begin();
while(beg!=s.end())
*beg= toupper(*beg++);
2. 算术运算符
3.逻辑和关系运算符
&& ||都是先求左值再求右值,当且仅当左侧值无法确定时才求右侧值,这种策略被称为短路求值。对于&&,当左侧为真时才对右侧运算对象求值;对于||当左侧为假时才对右侧求值。
4. 赋值运算符
赋值运算符满足右结合律
赋值语句可以出现在条件中,可以加括号使其符合原意。while((i=get())!=42)
符合赋值运算符:+=,-=,*=...
5. 递增和递减运算符
递增运算符(++)和递减运算符(--)为对象的加一减一提供一种简洁书写方式,还可用于迭代器。
递增和递减运算符有两种形式:前置和后置。前置会将运算对象加一,然后将改变后的对象作为求值结果。后置也会加一,但是求值结果是运算对象改变之前那个值的副本。
除非必须,否则不用递增递减运算符的后置版本:前置版本避免了不必要工作,它把值+1后直接返回了改变后的运算对象。后置版本需要将原始值存下来以便返回这个未修改的内容,会造成浪费。
在一条语句中混用解引用和递增运算符:若想在一条复合表达式中进将变量+-1,又使用它原来的值,这时可以使用后置版本。
/**
* 后置递增优先级高于解引用,所以
* *s++等价于*(s++)
*/
vector<int> v;
auto s=v.begin();
while(s!=v.end()&&*s>=0)
cout<<*s++<<endl;//先访问原值,再递增
运算对象可按任意顺序求值:同上面三1.3求值顺序问题
string s="das";
for(auto it=s.begin();it!=s.end();++it)
*it= toupper(*it);//当前字符改成大写
//在上述程序中我们吧解引用和,递增分开完成
/**
* 下面看似等价,实际错误。在一条语句中有两个部分
* 用到beg,但是有一个对其修改了
*/
auto beg=s.begin();
while(beg!=s.end())
*beg= toupper(*beg++);
6. 成员访问符
点运算符和箭头运算符都可以用于访问成员。其中点运算符获取类对象的一个成员;箭头运算符与点运算符有关,表达式ptr->mem等价于(*ptr).mem
7. 条件运算符
条件运算符?:,允许我们把简单的if-else逻辑嵌入到单个表达式中。cond?expr1:expr2。若cond为真则执行expr1,否则指向expr2。
int grade=10;
string finalGrade=(grade<60)?"fail":"pass";
//嵌套条件运算符
finalGrade=(grade>90)?"high pass"
:(grade<60)?"fail":"pass";
//在输出表示中使用条件运算符, 注意括号的使用
cout<<((grade<60)?"fail":"pass");//pass或fail
cout<<(grade<60)?"fail":"pass";//1或0(cout<<(grade<60)输出1or0, cout?"fail":"pass",根据cout的值是true还是false)
cout<<grade<60?"fail":"pass";//✖,视图比较cout和60, cout<<grade,cout<60
8. 位运算符
位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。位运算提供检查和设置二进制位的功能,bitset能表示二进制位,所以位运算对其也有用。
移位运算符(IO运算符)满足左结合律:
cout<<"hi"<<"here"<<endl;
//左结合律等价于
((cout<<"hi")<<"there")<<endl;
9. sizeof运算符(右结合律)
sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof所得值是一个size_t类型的常量表达式。
两种形式
sizeof(type)//类型
sizeof expr//表达式
//sizeof并不实际计算其运算对象的值
Sales_data data, *p;
sizeof(Salse_data);//存储Sales_data类型对象所占的空间大小
sizeof data;//data的类型大小,等价于第一个
sizeof p;//指针所占空间大小
sizeof *p;//p所指类型的空间大小,即等价于第一个
sizeof data.revenue;//Sales_data的reveneue成员对应类型大小
sizeof Sales_data::revenue;//等价于上面一条
/**
* sizeof和*优先级一样,且sizeof是右结合律,所以先解引用
* 其次,sizeof不会实际求运算对象的值,所以即使p未初始化也无影响
*/
/**
* 对char或类型为char的表达式执行sizeof,结果为1
*
* 对引用类型,得到被引用对象所占空间大小
*
* 对指针,得到指针所占空间大小
*
* 对解引用指针,得到指针所指向对象所占空间大小,指针不必有效
*
* 对数组,得到整个数组所占空间大小,等价于对数组中所有元素各执行一次sizeof
* 运算并将结果求和。sizeof不会将数组转化为指针处理。sizeof(a)/sizeof(*a)得到数组元素个数
*
* 对string或vector,只返回该类型固定部分的大小,不会计算对象中元素占用多少空间
*/
10. 逗号运算符
逗号运算符含有两个运算对象,按照从左向右的顺序依次求值,和逻辑与丶逻辑或丶条件运算符一样,逗号运算符也规定了运算对象求值顺序。
vector<int> v;
vector<int>::size_type cnt=v.size();
for(auto i=0;i!=v.size();++i, --cnt)
v[i]=cnt;
11. 类型转换
在C++中,某些类型之间有关联。如果两种类型有关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联对象的值去替代。若两种类型可以相互转换,那么他们就是关联的。
/**
* 加法的两个运算对象类型不同:一个double,一个int
* C++语言不会直接将两个不同类型的值相加,而是先根据类型转换规则将
* 运算对象统一后再求值。
* 上面是自动执行的,被称作隐式转换。
*/
int i=3.541+3;//i为6, 编译器可能会警告损失了精度
/**
* 算数类型间的隐式转换会尽可能避免损失精度。有整型和浮点型,则会转化
* 为浮点型。=右边表达式结果是浮点型
*
* 但是初始化过程中,被初始化对象类型无法改变,所以加法部分的浮点型被转化为整型
*/
①算术转换
算术转换:把一种算术类型转化成另外一种算术类型。(运算对象将会转化为最宽的类型)
整型提升:把小整数类型转化成较大的整数类型。对于bool, char, signed char, unsigned char, short, unsigned short等类型来说,只要他们所有可能的值都能存到int里,他们就会提升为int;否则,提升unsigned int。
较大的char(wchar_t, char16_t, char32_t)提升成int, unsigned int, long, unsigned long, long long, unsigned long long中最小的一种类型,前提是转化后的类型能容纳原类型所有可能的值。
无符号类型的运算对象:首先执行整型提升,如果结果的类型匹配,无需进行进一步转换。若两个提升后的运算对象类型要么都是带符号,要么都是不带符号,则小类型运算对象转换成较大的类型。
如果一个运算对象时无符号,另外一个是带符号,且无符号不小于带符号,那么带符号将会转化为无符号类型。比如int->unsigned int,若int为负则会有负作用。
若带符号类型>无符号类型,此时转换结果依赖于机器。(P142)
②其他隐式类型转换
数组转化为指针:在大多数用到数组的表达式中,数组自动转化为指向数组首元素的指针。
指针的转换:常量整数值0或字面值nullptr能转化成任意类型指针;指向非常量的指针能专户为void*;指向任意对象的指针能转化成const void*;在有继承关系的类型间还有另外一种转化方式。
转化成布尔类型:存在算术类型或指针类型向布尔类型自动转换的机制。若指针或算术类型值为0,转化结果为false, 否则为true
转换成常量:允许将指向非常量类型的指针或引用转化成指向相应的常量类型的指针或引用。
int i;
const int &j=i;//非常量转化为const int的引用
const int *p=&i;//非常量地址,转化为const的地址
int &r=j, *q=p;//✖,不允许const转化为非常量
类类型定义的转换:类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。
string s, t="hello";//字符串字面值转化为string类型
while(cin>>s);//把cin转化为布尔值, 读入成功是true,否则为false。IO库定义了
//从istream到布尔值的转化规则
③显示转换
有时想要显式的将对象强制转化为另外一种类型,需要使用某种方法,这种被称作强制类型转换。
命名的强制类型转换:形式:cast-name<type>(expression)
type是转换的目标类型,expression是要转换的值。若type是引用类型,则结果是左值。cast-name是static_cast丶dynamic_cast丶const_cast和reinterpret_cast中的一种。dynamic_cast支持运行时类型识别,将在第十八章介绍。
int i, j;
/**
* static_cast
* 任何具有明确意义的类型转换,只要不包含底层const, 都可以
* 使用static_cast;
*/
double slope=static_cast<double>(j)/i;//强制类型转换,以便执行浮点数除法
/**
* 当需要把一个较大的算术类型赋值给较小的类型时,static_cast有用。此时,
* 强制类型转化告诉编译器,不在乎精度损失,警告信息会关闭
*/
/**
* static_cast对于编译器无法自动执行的类型转换有用。例如,我们
* 可以使用static_cast找回存在与void*指针中的值
*/
void* p=&i;//任何非常量对象的地址都能存入
int *dp=static_cast<int*>(p);//将void*转化为初始的指针类型
/**
* const_cast
* const_cast只能改变运算对象的底层const:常量指针->非常量指针,常量引用->非常量引用。或者添加常量限制。具体看=左右的类型。
* 对于将常量对象转换成非常量对象的行为,称为“去掉const性质”
* 一旦去掉某个对象的const性质,编译器将不再阻止对该对象写操作。
* 若对象本身不是一个常量,使用强制类型转换获得写权限是合法的。
* 然而若对象是一个常量,再使用const_cast执行写操作会产生未定义后果
*/
const char *pc;
char *p1=const_cast<char*>(pc);//✔,但通过p1写值是未定义行为
/**
* 只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式
* 的常量属性都将引发编译器错误。同样,不能用const_cast改变表达式的类型
*/
const char *cp;
char *q=static_cast<char*>(cp);//✖,static_cast不能去掉const性质
static_cast<string>(cp);//✔,字符串字面值专换为string类型
const_cast<string>(cp);//✖,const_cast只改变常量属性,不改变类型
/**
* const_cast常用于有函数重载的上下文中
*/
/**
* reinterpret_cast
* 通常为运算对象的位模式提供较低层次上的重新解释。
*/
int *ip;
char *p2=reinterpret_cast<char*>(ip);
/**
* pc所指向的真实对象时一个int而非字符,若把pc当成普通的字符指针
* 使用可能在运行时发生错误,例如:string str(pc);
*/
旧式的强制类型转换:早期C++版本,显示进行强制类型转换包含两种形式
type(expr);//函数形式的强制类型转换
(type) expr;//C语言风格的强制类型转换
旧式强制类型转换与上面三种有相似行为。
int *ip;
char *pc=(char*) ip;//ip是指向整数的指针,结果同reinterpret_cast
12. 运算符优先级表(从上到下,从左至右优先级降低)
四. 语句
1. 简单语句
非定义声明语句只能在函数内。
int i;
cout<<i;//表达式语句
;//空语句
//复合语句
while(i<10) {
i=11;
}
2. 语句作用域
{}内或while后一条语句
3. 条件语句
①if语句
if(condition)//条件
statement//语句
else
statement
else会与离他最近的尚未匹配的if匹配
②switch
switch语句提供了一条遍历的途径使得我们能够在若干固定选项中做出选择。
/**
* switch先对括号里表达式求值,表达式的值转化为整数类型
* 然后与每个case标签比较,若匹配成功,从该标签之后的第一
* 条语句开始执行,直到到达switch结尾或者遇到break
*/
char ch;
int aCnt=0, bCnt=0;
while(cin>>ch)
{
switch (ch)
{
case 'a':
++aCnt;
break;
case 'b':
++bCnt;
break;
}
}
/**
* case标签必须是整型常量表达式
*/
int i=42;
switch (ch) {
case 3.14://✖,不是整数
case i://✖,不是常量
}
/**
* default
* 若没有一个case匹配上就会执行default后的语句
*/
switch (ch)
{
case 'a':
case 'b':
++abCnt;
break;
default:
++otherCnt;
break;
}
/**
* 若case内有定义语句,应放在块内,控制其范围
* 以防后面用到它
*
* 否则若分支1定义,而执行了分支2,会找不到变量
*/
4. 迭代语句
①while语句
while(condition)//当条件为真,下面语句会一直执行
statement
②传统for
/**
* init-statement必须是声明语句,表达式语句,空语句中的一种
* 先执行init-statement->condition->statement->expression
*/
for(init-statement;condition;expression)
statement
③范围for
/**
* expression表示的必须是一个序列,比如:用{}括起来的数值列表
* 丶数组丶vector丶string等类型对象,这些类型共同特点是能返回begin和end成员
*
* declaretion定义了一个变量,序列中每个元素都能转化为该变量的类型
*
* 每次迭代都会重新定义declaration并将其初始化为下一个值,之后执行statement
*/
for(declaration:expression)
statement
/**
* 范围for定义来源于与之等价的传统for。
* 所以不能在范围for内修改自身元素
*/
for(auto beg=v.begin(), e=v.end();beg!=end;beg++)
④do while语句
do while先执行,再检查条件。
do {
statement
}while(condition)
5. 跳转语句
①break
break语句负责终止离他最近的whie丶do while丶for丶switch语句,并从这些语句后的第一条语句开始继续执行。
②continue
终止最近的循环中的当前迭代并立即开始下一次。
与break不同的是,只有当switch语句嵌套在迭代语句内部时,才能在switch里用continue。
③goto
从goto语句无条件跳转到同一函数的另一条语句。
/**
* goto语句的语法形式:
* goto label;
* label是用于标识一条语句的标示符。带标签语句是一种特殊的语句,在它之前有一个
* 标示符以及一个冒号:
* end: return;//带标签语句,可作为goto目标
*
* 标签标识符独立于变量或其他标示符名字,因此标签标示符可以和程序中其他实体的标示符
* 使用同一个名字,互不干扰。goto语句和控制权转向的那条带标签的语句必须位于同一个
* 函数内。
*/
goto end;
int ix=10;//✖,goto绕过了一个带初始化的变量定义
end:
ix=42;//✖,ix需要定义,但是goto染过了它的声明
/**
* 向后跳过一个已经执行的定义是合法的。跳回到变量定义前意味着系统
* 销毁该变量并重新定义他
*/
begin:
int sz=get_size();
if(sz<=0) {
goto begin;
}
6. try语句块和异常处理
异常:指存在于运行时的反常行为,这些行为超出了函数正常的功能范围。
当程序遇到他无法处理的问题时,需要异常处理。
异常处理:包含throw表达式丶try语句块丶一套异常类
①throw表达式
异常检测部分使用throw表达式来表示它遇到无法处理的问题,所以说throw引发了异常。
int i, j;
if(i!=j)
throw runtime_error("i不等于j");
/**
* runtime_error是标准异常类型的一种,定义在
* stdexcept头文件中。
* 可硬string对象或C风格字符串对其初始化信息
*/
②try语句块
异常处理部分使用try语句块。以try开始,并以一个或多个catch语句结束try语句块中代码抛出的异常会被某个catch处理
try{
program-statements
} catch(exception-declaration){
}catch(xxxx){
}
int i, j;
try
{
if(i!=j)
throw runtime_error("i不等于j");
}catch (runtime_error err) {
cout<<err.what();//上面初始化的副本
}
/**
* runtime_error是标准异常类型的一种,定义在
* stdexcept头文件中。
* 可硬string对象或C风格字符串对其初始化信息
*
* 若上面产生异常,则下面捕获并处理
*/
在复杂系统中,程序在遇到抛出异常的代码前,其执行路径可能已经经过多个try语句块。例如,一个try语句块可能调用包含了另一个try语句块的函数,新的try语句块的函数又调用了包含又一个try语句块的新函数,以此类推。
寻找处理代码的过程与函数调用正好相反。当异常被抛出时,首先搜索抛出该异常的函数。若没找到匹配的catch子句,终止该函数,并在调用该函数的函数中寻找。若还没找到,这个新函数也将被终止,继续搜索调用它的函数。以此类推。
如果最终还没能找到任何匹配catch子句,程序转到名为terminate的标准库函数,该函数的行为与系统有关,一般,执行该函数会导致程序非正常退出。
③标准异常
用于报告标准库函数遇到的问题。这些异常类可用在用户编写的程序中,它们分别定义在4个头文件中:
exception头文件:定义了最通用的异常类exception,只报告异常,不提供额外信息。
stdexcept头文件:下表显示
new头文件:定义了bad_alloc异常类型。(十一介绍)
type_info头文件:定义了bad_cast异常类型。(十八介绍)
只能以默认初始化的方式初始化exception丶bad_alloc丶bad_cast对象,不允许为这些对象提供初始值。
其他异常类型:应用string对象或C风格字符串初始化这些对象,而不允许使用默认初始化的方式。
异常类型只定义了一个名为what的成员函数,该函数无任何参数,返回值是一个指向C风格字符串的const char*。该字符串目的是提供关于异常的文本信息。
what函数返回的C风格字符串内容与异常对象的类型有关。若异常有一个字符串初始值,则返回该字符串。对于其他无初始值的异常类型来说,what返回内容由编译器决定。
五. 函数
1. 函数基础
典型函数包含:返回类型丶函数名字丶0个或多个形参组成的列表以及函数体。
int getSum(int a, int b)
{
return a+b;
}
signed main()
{
int v= getSum(1, 2);
}
函数调用:一是用实参初始化函数对应的形参,二是将控制权转移给被调函数。此时,主调函数执行被暂时中断,被调函数开始执行。
执行函数第一步是(隐式)定义并初始化它的形参。当遇到return语句时结束执行过程。return语句:返回return中的值,将控制权从被调函数转移到主调函数。
形参和实参:实参是形参的初始值。实参类型必须和对应的形参类型匹配。(实参double, 形参int, 会隐式转换成int)
函数的形参列表:形参列表可以为空(或void:void f(void){}), 形参不能重名。
函数返回类型:不返回值则为void, 别的大多数类型都能用于函数返回类型。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
①局部对象
在C++中,名字有作用域,对象有生命周期。函数体是一个块,块构成新的作用域。形参和函数体内部定义的变量统称为局部变量。它们仅在函数的作用域内可见,同时局部变量还会隐藏在外层作用域中同名的所有其他声明中。
在所有函数体之外定义的对象存在于整个程序的整个生命周期中。此类对象在程序被启动时创建,直到程序结束才会销毁。局部变量的声明周期依赖于定义方式。
自动对象:对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当达到定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。
形参是一种自动对象。我们传递给函数的实参初始化形参对应的自动对象。对于局部变量对应的自动对象来说,则分为两种情况:若变量定义本身含初始值,则用这个值初始化;否则,如果变量定义本身不含初始值,执行默认初始化意味着:内置类型未初始化局部变量将产生未定义的值.
局部静态对象:若要令局部变量的生命周期贯穿函数调用及之后的时间。可将局部变量定义成static类型。局部静态对象:在程序的执行路径第一次经过该对象定义语句时初始化,并且直到程序终止才销毁,在此期间即使对象所造的函数执行结束也不会对她有影响。
若局部静态变量没有显示的初始值,他将执行值初始化(二3①),内部类型的局部静态变量初始化为0.
int cout_calls()
{
static int ctr=0;//函数调用结束,这个值仍然有效
return ++ctr;
}
signed main()
{
/**
* 下面函数统计自己被调用多少次
* 在控制流第一次经过ctr定义之前, ctr被创建初始化为0.
* 每次调用把ctr+1并返回新值。每次执行count_calls函数时
* ctr已存在并等于上次退出时的值
*/
for(int i=0;i<10;++i)
cout<<cout_calls()<<' ';//1 2 3 4 5 6 7 8 9 10
}
②函数声明
函数的名字必须在使用前声明。类似于变量,函数只能定义一次,但是可以声明多次。
函数声明和函数定义类似,唯一区别是函数声明无需函数体,用一个分号替代即可。
因为函数的声明不包含函数体,所以也就无需形参的名字,但是最好加上。
函数三要素:返回类型,函数名,形参类型
#include <iostream>
using namespace std;
int getSum(int, int);//函数声明
signed main()
{
getSum(1, 2);//函数调用
}
int getSum(int a, int b)//函数定义
{
return a+b;
}
在头文件中进行函数声明:变量和函数都应在头文件中声明,在源文件中定义(定义只能定义一次)。以便统一。
③分离式编译
随着程序越来越复杂,我们希望把程序的各个部分分别存储在不同文件中。C++为了实现这点,支持分离式编译。
编译和链接多个源文件:假设fact函数定义位于一个fact.cc的文件中,它的声明位于名为Chapter6.h的头文件中。和其他所有用到fact函数的文件一样,fact.cc应该包含Chapter6.h头文件。若我们修改了其中一个源文件,那么只需重新编译那个改动了的文件。
2. 参数传递
引用传递:形参的类型决定了形参和实参交互的方式。若形参是引用类型,它将绑定到对应的实参上(即别名);否则,将实参的值拷贝后赋给形参。
值传递:当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。
①传值参数
当初始化一个非引用类型的变量,初始值被拷贝给变量。此时,对变量的改动不会影响初始值。函数对形参所有的操作不会影响实参。
指针形参:指针的行为和其它非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值。拷贝后两个指针是不同的指针。但是指针可以间接访问它所指的对象,所以可以通过形参指针来修改它所指向的对象。
void reset(int *ip)
{
*ip=0;//改变指针ip所指对象的值
ip= nullptr;//只改变了ip的局部拷贝,实参未改变
}
int ip;
reset(&ip);//传地址, 指针的值是地址,改变ip的值而非ip的地址
C++中建议使用引用类型的形参代替指针。
②传引用参数
通过引用形参,允许函数改变一个或多个实参的值。
void reset(int &ip)
{
ip=0;//改变了ip所引对象的值
}
signed main()
{
int ip=2;
reset(ip);//传递对象
}
使用引用避免拷贝:拷贝大的类型对象或容器对象比较低效,甚至有的类型不支持拷贝操作。当某种类型不支持拷贝操作时,只能通过引用形参访问该类型的对象。
若函数无需改变引用形参的值,最好将其声明为常量引用。
bool isShorter(const string &s1, const string &s2)
{
return s1.size()<s2.size();
}
使用引用形参返回额外信息:一个函数只能返回一个值,然而有时函数需要返回多个值,通过引用形参可以实现这一点。
/**
* 查找字符c在字符串s中出现的次数occurs
* @param s
* @param c
* @param occurs
* @return 第一次出现的位置
*/
string::size_type find_char(const string &s, char c, string::size_type &occurs)
{
auto ret=s.size();
occurs=0;
for(decltype(ret) i=0;i>s.size();++i)
if(s[i]==c)
{
ret=i;
++occurs;
}
return ret;//出现次数通过occurs隐式的返回
}
③const形参和实参
当形参是const时,要注意关于顶层const的讨论。和其它初始化过程一样,当用实参初始化形参时会忽略掉顶层const。即,形参的顶层const被忽略了,当形参有顶层const时,传给它常量对象或非常量对象都可以(因为是初始化, 常量可以指向非常量)。
//可传const int 或 int
void fcn(const int i){}//fcn能够读取i,但是不能向i写值
void fcn(int i){}//✖, 因为形参忽略了顶层const, 所以造成重复定义函数:Redefinition of 'fcn'
指针或引用形参与const:形参的初始化和变量初始化是一样的。可以用非常量初始化一个底层const对象,但是反过来不行;同时一个普通的引用必须用同类型初始化。
int j=42;;
const int *cp=&j;//底层const, ✔,但是cp不能改变i
const int &r=j;//底层const, ✔,但是r不能改变i
const int &r2=42;//✔
int *p=cp;//✖,p和cp不匹配,非常指向常
int &r3=r;//✖,同上
int &r4=42;//✖,不能用字面值初始化一个非常量引用
/**
* 由上可知
*/
int i=0;
const int ci=i;
string::size_type ctr=0;
reset(&i);//✔,rest(int *i)
reset(&ci);//✖, 不能用指向const int对象的指针初始化int*
reset(i);//✔,reset(int &i);
reset(ci);//✖不能把普通引用绑定到const对象ci上
reset(42);//✖,不能把普通引用绑定到字面值上
reset(ctr);//✖,类型不匹配,ctr是无符号类型
find_char("hello", 'o', ctr);//✔,find_char(const string &s, char c, string::size_type &occurs)
//第一个形参是对常量的引用
尽量使用常量引用:把函数不会改变的形参定义成普通引用是错误的,这给函数调用者一种误导,即函数可以修改它的实参的值。
//函数只能作用于string对象
//find_char("hello")将会是错误的,限制函数的使用
string::size_type find_char(string &s);
bool is_sentence(const string &s)
{
//✖
return find_char(s);//find_char只能接受普通引用,而s是常量引用
}
④数组形参
数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响:不允许拷贝数组和使用数组时通常会转化成指针。
因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转化为指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。但我们可以将形参写成类似数组的形式
/**
* 尽管形式不同但是实际上是等价的
* const int*类型形参
*/
void print(const int*);
void print(const int[]);
void print(const int[10]);//这里是期待数组有多少元素,实际不一定
数组形参和const:三个print函数都把数组形参定义成指向const的指针,关于引用的讨论同样适用于指针。当函数不需要对数组元素执行写的操作时,数组形参应该是指向const的指针。
数组引用形参:C++允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上:
//✔,形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10]);
//✖,将arr声明成了引用的数组,引用不是对象不存在引用的数组
void print(int &arr[10]);
传递多维数组:多维数组就是数组的数组。当把多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略。
//a指向数组的首元素,该数组的元素是由10个整数构成的数组
void print(int (*a)[10]);
int *a[10];//10个指针构成的数组
int (*a1)[10];//指向含有10个整数的数组的指针
/**
* 用数组语法定义函数,下面a看起来像是一个二维数组,实际上形参是指向含有10个整数
* 的数组的指针。编译器会忽略掉第一个维度
* @return
*/
void print(int a[][10]);
⑤main:处理命令行选项
main函数传递实参时:通过用户设置一组选项来确定函数所要执行的操作。例如main函数位于可执行文件prog之内,我们可以向程序传递下面的选项:prog -d -o ofile data0, 这些命令行选项通过两个(可选)形参传递给main函数:
/**
* @param argc 表示数组argv中字符串的数量
* @param argv 一个数组,它的元素是指向C风格字符串的指针
* @return
*/
int main(int argc, char *argv[]){}
/**
* 因为上面第二个形参是数组(传递指向首元素char*的指针),所以也可以定义成下面这种
* @param argc
* @param argv 指向char*
* @return
*/
int main(int argc, char **argv){}
/**
* 当实参传给main函数后, argv的第一个元素指向程序的名字或空字符串
* 接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为0
*
* 以上面提供的命令为例子,argc为5, argv如下
*/
argv[0]="prog";//程序名字
argv[1]="-d";//可选实参从1开始
argv[2]="-o";
argv[3]="ofile";
argv[4]="data0";
argv[5]=0;
⑥含有可变形参的函数
有时无法提前预知应该传几个参数。C++11提供了两种方法:若所有的实参类型相同,可以传递一个名为initializer_list的标准库类型;若实参类型不同,可以编写一种特殊的函数,即可变参数模板。
C++还有一种特殊的形参类型(省略符),可以用它传递可变数量的实参(一般用于与C函数交互的程序接口)。
initializer_list形参:若实参数量未知,但其类型相同,我们可以使用initializer类型的形参。其是一种标准库类型,用于表示某种特定类型的值的数组。其定义在同名的头文件中。
同vector一样,initializer_list也是一种模板类型,定义其时要说明列表中所含元素的类型。但与vector不同的是,initializer_list对象中的元素是常量值,不能改变。
void error_msg(initializer_list<string> il)
{
for(auto beg=il.begin();beg!=il.end();++beg)
cout<<*beg<<" ";
}
int main()
{
initializer_list<string> ls;
error_msg(ls);
error_msg({"a", "b"});
error_msg({"a", "b", "c"});//传递值序列
}
省略符形参:省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。
/**
* 第一种形式指定了foo函数的部分形参类型,对这些形成的实参会
* 正常执行类型检查。省略符形参所对应的实参无需类型检查。
* 在第一种形式中,形参声明后面的逗号是可选的。
* @param ...
*/
void foo(param_list, ...);
void foo(...);
3. 返回类型和return语句
return语句终止当前正在执行的函数,并将控制权返回到调用该函数的地方,其有两种形式。
return;
return expression;
①无返回值函数
没有返回值的return只能用在返回类型是void的函数。返回void的函数不要求一定有return, 因为这类函数的最后一句后面会隐式的执行return。
一个返回值类型是void的函数也可以用第二种形式,此时return语句的expression必须是另一个返回void的函数。
②有返回值函数
只要返回值类型不是void,则该函数内的每条return语句必须返回一个值。return语句返回值类型必须与函数返回值类型相同,或这能隐式转换成其返回值类型。
返回值是如何被返回的:返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
/**
* 该函数返回类型是string,意味着返回值将被拷贝到调用点。
* 因此,该函数将返回word的副本或者一个临时未命名的string对象(wordending)
*/
string getCnt(int ctr, const string &word, const string &ending)
{
return (ctr>1)?word+ending:word;
}
/**
* 同其他引用类型一样,若函数返回引用,则引用仅是它所引对象的一个别名。
* 下面形参和返回类型都是const string的引用,不管是调用函数还是返回
* 结果都不会真正拷贝string对象
*/
const string &shorterString(const string &s1, const string &s2)
{
return s1.size()<=s2.size()?s1:s2;
}
不要返回局部对象的引用或指针:函数完成后,它所占用的空间将被释放。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。
const string &manio()
{
string ret;
if(!ret.empty())
return ret;//✖,返回局部对象的引用
return "Empty";//✖,“Empty”是一个局部临时变量
}
返回类类型的函数和调用运算符:和其他运算符一样,调用运算符也有优先级和结合律。调用运算符的优先级与点运算符和箭头运算符相同,且也为左结合律。因此,若函数返回指针丶引用或类的对象,就可以使用函数调用的结果访问结果对象的成员。int sz=short(s1, s2).size();
引用返回左值:函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。
char &getVal(string &str, string::size_type ix)
{
return str[ix];
}
int main()
{
string s("a value");
cout<<s<<endl;//a value
getVal(s, 0)='A';//将s[0]改为'A'
cout<<s<<endl;//A value
}
列表初始化返回值:C++11规定,函数可以返回花括号包围值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。若列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。
若函数返回的是内置类型,则{}最多包含一个值,且该值所占空间不应该大于目标类型的空间。若函数返回的是类类型,由类本身定义初始值如何调用。
vector<string> process()
{
int cnt=1;
string str="abc";
if(cnt==1) return {};//返回空vector对象
if(cnt==2) return {"fc", "ok"};//返回列表初始化的vector对象
return {"fc", "ok", str};
}
主函数main的返回值:函数的返回类型不是void,必须返回一个值。但是这条规则有个例外:允许main函数没有return语句直接结束。若控制到达了main函数的结尾处而没有return语句,编译器将隐式的插入一条返回0的return语句。
main函数的返回值可看做状态指示器,返回0表示成功,其他值表示失败,其中非0值的具体含义由机器而定。为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量来表示成功和失败。
//预处理变量,前面不能加上std::, 也不能在using声明中出现
if(出现错误) return EXIT_FAILURE;
return EXIT_SUCCESS;
递归:一个函数直接或间接的调用自身。
③返回数组指针
因为数组不能被拷贝,所以函数不能返回数组。但是,函数可以返回数组的指针或引用。虽然语法上定义一个返回数组的指针或引用的函数比较繁琐,但是有一些方法可以简化,最直接方法就是使用类型别名。
typedef int arrT[10];//arrT是一个类型别名,其类型是含有10个整数的数组
using arrT=int[10];//同上
arrT* func(int i);//返回一个指向含有10个整数的数组的指针
声明一个返回数组指针的函数:若想在声明func时不使用类型别名,必须牢记被定义的名字后面数组的维度。
int arr[10];//含有10个整数的数组
int *p1[10];//p1是一个含有10个指针的数组
int (*p2)[10]=&arr;//p2是一个指针,它指向含有10个整数的数组
/**
* 与上面相同,若想定义一个返回数组指针的函数,则数组的维度必须跟在
* 函数名字后。
* 然而,函数形参列表也跟在函数名字后面且先于数组的维度。因此
* 返回数组指针的函数形式如下所示:
*/
Type (*function(parameter_list))[dimension]
/**
* 类似于其他数组的声明,Type表示元素类型, dimension表示数组的大小
* (*function(parameter_list))两端的括号必须存在,如图p2一样。
* 若无括号,函数返回类型将是指针的数组
*/
int (*func(int i))[10];//返回一个指针,它指向含有10个整数的数组
使用尾置返回类型:在C++11中有一种可简化上述func声明的方法,即:尾置返回类型。任何函数定义都能使用尾置返回,但是这种形式对返回类型比较复杂的函数最有效,比如返回类型是数组的指针或数组的引用。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表后,我们在本应该出现返回类型的地方放置一个auto
/**
* 返回一个指针,该指针指向含有10个整数的数组
*/
auto func(int i) -> int(*)[10];
使用decltype:若我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数i的不同指向两个已知数组中的某一个:
int odd[]={1, 3, 5, 7, 9};
int even[]={0, 2, 4, 6, 8};
decltype(odd) *arrPtr(int i)//decltype不负责把数组转化成指针,所以加*
{
return (i%2)?&odd:&even;//返回一个指向数组的指针
}
4. 函数重载(main函数除外)
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载函数。
是否重载函数要看是否容易理解(重载前后)。
/**
* 这些函数接受形参类型不同,
* 编译器会根据传递的实参类型推断想要的是哪个函数
*/
void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);
//根据不同类类型调用函数
Record look(const Account&);
Record look(const Name&);
//有时候两个形参列表看起来不同,但是实际上相同
Record look(const Account &acct;
Record look(const Account&);//省略了形参名字
typedef Phone Telno;
Record look(const Phone&);
Record look(const Telno&);//Phone和Telno类型相同
重载和const形参:顶层const不影响传入函数的对象。一个拥有顶层const的形参无法和另一个没有顶层const的形成区分开来。
//顶层const
Record look(Phone);
Record look(const Phone);//重复声明Record look(Phone);
Record look(Phone*);
Record look(Phone* const);//重复声明Record look(Phone*);
//底层const
//若形参是某种类型的指针或引用,则通过区分其指向的是常量对象
//还是非常量对象可以实现重载,此时const是底层的
//对于接受引用或指针的函数来说,对象时常量还是非常量对应的形参不同
//编译器通过实参是否为常量判断使用哪个函数。因为const不能转换成其他类型,所以
//只能把const对象,传递给const形参。相反的,因为非常量可以转换成const, 所以
//上面的4个函数都能作用于非常了对象或指向非常量对象的指针,不过编译器会优先使用
//非常量版本
Record look(Account&);
Record look(cosnt Account&);//新函数,常量引用
Record look(Account*);
Record look(const Account*);//新函数,底层const,作用于指向常量的指针
const_cast和重载:const_cast在重载函数的情景中最有用。
/**
* 这个函数的参数和返回类型都是const string的引用
* 我们可以对两个非常量的string实参调用这个函数,但是返回的结果
* 仍然是const string的引用。
* 因此,我们需要一种新的shorterString函数,当他的实参不是常量时,
* 得到的结果是一个普通的引用,使用const_cast可以做到这一点
*/
const string &shorterString(const string &s1, const string &s2)
{
return s1.size()<=s2.size()?s1:s2;
}
/**
* 在下面中,首先将实参强制转换成对const的引用,然后调用了
* shorterString函数的const版本。const版本返回对const string的引用,
* 这个引用事实上绑定在了某个初始的非常量实参上。因此,我们可以将其再转换成一个
* 普通的string&,这是安全的
*/
string &shorterString(string &s1, string &s2)
{
//r为const string&
auto &r= shorterString(const_cast<const string&>(s1),
const_cast<const string&>(s2));//添加const性质
return const_cast<string&>(r);//移除const性质
}
调用重载的函数:定义了一组重载函数后,根据实参去调用。大多数比较容易确定,但是若函数调用存在类型转换时,编译器的处理方法在第五组第6节介绍。
调用重载函数时三种可能结果:编译器找到一个与实参的最佳匹配的函数,并生成调用该函数的代码;找不到与实参匹配,此时发出无匹配的错误;有多于一个函数可以匹配,但是每一个都不是明显的最佳选择,此时将发生错误,称为二义性调用。
①重载与作用域
重载对作用域的一般性质并没有什么改变:如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。(一般不把函数声明放在局部作用域,但是为了说明作用域和重载的相互关系,我们暂时违背这一原则而使用局部函数声明)。
string read();
void print(const string &);
void print(double);//重载print函数
/**
* 下面一旦调用print函数时,编译器首先寻找对该函数的声明,找到的是接受
* int的那个局部声明。一旦在当前作用域中找到了所需的名字,编译器会忽略掉
* 外层作用域中的同名实体。剩下就是检查函数调用是否有效了。(在C++中
* 名字查找发生在类型检查前)
* @param ival
*/
void fooBar(int ival)
{
bool read=false;//新作用域:隐藏了外层的read
string s=read();//✖,read是一个布尔值
//不好的习惯:通常不在局部作用域中声明函数
void print(int);//新作用域:隐藏了之前的print,没有重载
print("Value");//✖,void print(const string &)被隐藏了
print(ival);//✔,当前print(int)可见
print(3.14);//✔,调用print(int);print(double)被隐藏了。3.14被转化为int
}
5. 特殊用途语言特性
三种函数相关的语言特性:默认实参、内联函数、constexpr函数以及一些在程序调试过程中常用的一些功能。
①默认实参
某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,我们把这个反复出现的值称为函数的默认实参。调用含有默认实参的函数,可以包含该实参,也可以省略该实参。
//我们为每个形参都提供了默认实参,默认实参作为形参的初始值出现在形参列表中。
//我们可以为一个或多个形参定义默认值,但是一旦某个形参被赋予了默认值,他后面所有的形参都
//必须有默认值
string screen(int ht=24, int wid=80, char backgrnd=' ');
int main()
{
//若想使用默认实参,只要在调用函数的时候省略该实参就可以了
//函数调用时,实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)
string window;
window= screen();//等价于screen(24, 80, ' ');
window= screen(66);//screen(66, 80, ' ');
window= screen(66, 256);//screen(66, 256, ' ');
window= screen(66, 256, '#');//screen(66, 256, '#')
}
默认实参声明:对于函数声明来说,通常放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过在给定的作用域中,一个形参只能被赋予一次默认实参。即,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。
string screen(int, int, char=' ');
string screen(int, int, char='*');//✖,重复声明
string screen(int=24, int=80, char);//✔,添加默认实参
默认实参初始值:局部变量不能作为默认实参。除此之外,只要表达式的类型能转化成形参所需类型,该表达式就能作为默认实参。
using sz=string::size_type;
sz wd=80;
char def=' ';
sz ht();
string screen(sz=ht(), sz=wd, char=def);
string window= screen();//调用screen(ht(), 80, ' ');
//用作默认实参的名字在函数声明所在的作用域解析,而这些名字在函数调用时求值
void f2()
{
def='*';//改变默认实参值
sz wd=100;//隐藏外层定义的wd,且没有改变默认值
window= screen();//screen(ht(), 80, '*')
}
②内联函数(inline)和constexpr函数
shorterString函数用于返回长度较短的string引用,将这种规模较小的操作定义成函数好处很多(统一操作,重用)。但是有一个潜在的缺点:调用函数一般比求等价表达式的值要慢。在大多数机器上,函数调用包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能要拷贝实参;程序转向一个新的位置执行。
内联函数可避免函数调用的开销:将函数指定为内联函数,通常就是将它在每个调用点上“内联地”展开。内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。一般来说,内联函数用于优化规模较小、流程直接、频繁调用的函数。很多编译器不支持内联递归函数,而且一个75行的函数也不大可能在调用点内联地展开。
/**
* 在函数的返回类型前加上关键字inline,这样就可以将它声明
* 为内联函数
*/
inline const string &
shorterString(const string &s1, const string &s2)
{
return s1.size()<=s2.size()?s1:s2;
}
int main()
{
string s1("a"), s2("ab");
cout<<shorterString(s1, s2)<<endl;
//将在编译过程中展开成类似下面的形式
cout<<((s1.size()<=s2.size())?s1:s2)<<endl;
}
constexpr函数:constexpr函数指能作用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循约定:函数的返回值及所有形参的类型都得是字面值类型(算术、引用、指针、字面值常量类等),且函数体中必须有且只有一条return语句。
constexpr int new_sz()
{
return 42;
}
// 因为编译器能在程序编译时验证new_sz返回的是常量表达式,所以可用
// 它去初始化foo
// 执行该初始化任务时,编译器把对constexpr函数的调用替换成其结果值,
// 为了能在编译过程中随时展开,constexpr函数被隐式的指定为内联函数。
//
// constexpr函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。
// 例如, constexpr函数中可以有空语句、类型别名以及using声明
constexpr int foo=new_sz();//✔, foo是一个常量表达式
// 允许constexpr函数的返回值并非一个常量
/**
* 当scale的实参是常量表达式时,它的返回值是常量表达式;反之,则不然
*/
constexpr size_t scale(size_t cnt)
{
return new_sz()*cnt;
}
int arr[scale(2)];//✔, scale(2)为常量表达式
int i=2;//i不是常量表达式
int a2[scale(i)];//✖,scale(i)不是常量表达式
把内联函数和constexpr函数放在头文件内:和其他函数不同,内联函数和constexpr函数可以在程序中被多次定义。不过,对于某个给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和constexpr函数通常定义在头文件中。
③调试帮助
C++程序有时用到一种类似头文件保护的技术,以便有选择的执行调试代码。基本思想是:程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当程序编写完成准备发布时,要先屏蔽调调试代码。这种方法用到两项预处理功能:assert和NDEBUG
assert预处理宏:assert是一种预处理宏。所谓预处理宏是一个预处理变量,它的行为类似于内联函数。assert宏使用一个表达式作为它的条件:assert(expr);
首先对expr求值,若表达式为假(即0),assert输出信息并终止程序运行。若表达式为真(非0), assert什么也不做。
assert宏定义在cassert头文件中。如我们所知,预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无需提供using声明。即,我们应该使用assert而不是std::assert,也无需为assert提供using声明
和预处理变量一样,宏名字在程序内必须唯一。含有cassert头文件的程序不能再定义名为assert的变量、函数或其他实体。在实际编程中,即使无cassert头文件,也最好不要为了其他目的使用assert。很多头文件都包含了cassert,这意味着可能cassert间接在你的程序中。
assert宏常用于检查“不能发生”的条件。比如,文本输入程序要求所有给定单词的长度大于某个阈值。assert(word.size()>10)。当word<10,程序停止执行。
NDEBUG预处理变量:assert的行为依赖于一个名为NEDBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认没有定义。
可以使用一个#define语句定义NDEBUG,从而关闭调试状态。同时,很多编译器提供了一个命令行选项使我们可以定义预处理变量:& CC -D NDEBUG main.c # use /D with the Microsoft compiler。这条命令的作用等价于在main.c文件的一开始写#define NDEBUG
除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。若NDEBUG未定义,将执行#ifndef和#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
/**
* 编译器为每个函数都定义了__func__,它是const char的一个
* 静态数组,用于存放函数名字
*/
void print(const int ia[], size_t size)
{
#ifndef NDEBUG
//__func__是编译器定义的一个局部静态变量,用于存放函数名字
cerr<<__func__ <<": array size is"<<size<<endl;
#endif
}
int main()
{
int a[]={1};
print(a, 1);
}
6. 函数匹配
大多数情况下,容易确定某次调用哪个重载函数。然而,当几个重载函数的形参数量相等以及某些形参的类型可以由其他类型转换得来时,就不容易了。
void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);
f(5.6);//调用void f(double, double)
确定候选函数和可行函数:函数匹配的第一步是选定本次调用的重载函数集,集合中的函数被称为候选函数。候选函数有两个特征:一是与被调用的函数同名,二是其声明在调用点可见。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同或者能转化为形参的类型。
经历上面两步,选出f(int)和f(double, double=3.14)
寻找最佳匹配(如果有的话):函数匹配第三步是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。“最匹配”:实参类型与形参类型越接近,匹配的越好。因此例子中调用f(double, double)
含有多个形参的函数匹配:当实参的数量有两个或更多时,函数匹配比较复杂。以(42, 2.56)为例子。
选择可行函数和一个实参时一样:f(int, int), f(double, double)。接下来编译器将依次检查每个实参以确定哪个函数是最佳匹配。如果只有一个函数满足以下条件,则匹配成功:该函数的每个实参的匹配都不劣于其他可行函数需要的匹配;至少有一个实参的匹配优于其他可行函数提供的匹配。
若检查后,没有任何一个函数符合,则调用错误,二义性调用。
上面的调用第一个参数符合f(int, int)最优,第二个实参符合f(double, double)最优,都需要强制转换某个实参。产生二义性调用。
①实参类型转换
为了确定最佳匹配,编译器将实参类型到形参类型的转换分成几个等级,具体排序如下所示:
需要类型提升和算术类型转换的匹配:小整型一般会提升到int类型或更大的整数类型。假设有两个函数,一个接收int,另一个接收short,则只有当调用提供的是short类型的值时才会选择short版本的函数。即使实参是一个很小的整数值,也会直接将他提升成int类型;此时使用short版本反而会导致类型转换。
所有算术类型转换级别都一样。从int向unsigned int的转换并不比从int向double的转换级别更高
void ff(int);
void ff(short);
ff('a');//char提升成int;调用ff(int)
void manip(long);
void manip(float);
manip(3.14);//✖,二义性调用;3.14为double,能转换成long也能转换成float
函数匹配和const实参:若重载函数的区别在于他们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数:
Record look(Account&);
Record look(const Account&);//常量引用
const Account a;
Account b;
//a是常量,所以只能匹配第二个
look(a);//调用Record look(const Account&)
//b非常量,1 2都可。但是1是精确匹配,无需转换
look(b);//调用Record look(Account&)
/**
* 指针类似。若两个函数唯一区别是它的指针形参指向常量或非常量,
* 则编译器能通过实参是否是常量决定调用哪个函数。
*/
7. 函数指针
函数指针指向的是函数而非对象。和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。
//该函数类型是:bool(const string &, const tring &)
bool lenCompare(const string &, const string &);
//要想声明一个指向该函数的指针,只需要用指针替换函数名即可
//pf指向一个函数,该函数参数和返回值如下
//pf前有*,表示是指针;右侧是形参列表,表示pf指向的是函数;左侧表示返回值是布尔
bool (*pf)(const string &, const string &);//未初始化
//pf是一个返回bool指针的函数,若不加()
bool *pf(const string &, const string &);
使用函数指针:当我们把函数名作为一个值使用时,该函数自动转化为指针。此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针。
在指向不同函数类型的指针间不存在转换规则。但与往常一样,可以为函数指针赋一个nullptr或值为0的常量表达式,表示指针没有指向任何一个函数。
//该函数类型是:bool(const string &, const tring &)
bool lenCompare(const string &, const string &);
//要想声明一个指向该函数的指针,只需要用指针替换函数名即可
//pf指向一个函数,该函数参数和返回值如下
//pf前有*,表示是指针;右侧是形参列表,表示pf指向的是函数;左侧表示返回值是布尔
bool (*pf)(const string &, const string &);//未初始化
string::size_type sumLen(const string &, const string &);
bool cstringCompare(const char*, const char*);
int main()
{
pf=lenCompare;//pf指向名为lenCompare的函数
pf=&lenCompare;//等价于上面:&是可选的
bool b1=pf("hello", "goodbye");//调用lenCompare
bool b2=(*pf)("hello", "goodbye");//等价调用
bool b3= lenCompare("hello", "goodbye");//等价调用
pf=0;//✔,pf不指向任何函数
pf=sumLen;//✖,返回类型不匹配
pf=cstringCompare;//✖,形参类型不匹配
pf=lenCompare;//✔
}
重载函数的指针:当我们使用重载函数时,上下文必须清晰的界定到底应该选用哪个函数。如果定义了指向重载函数的指针,编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配。
void ff(int*);
void ff(unsigned int);
void (*pf1)(unsigned int)=ff;//pf1指向ff(unsigned int)
void (*pf2)(int)=ff;//✖,没有任何一个ff与该形参匹配
double (*pf3)(int*)=ff;//✖,ff和pf3的返回类型不匹配
函数指针形参:和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用。我们可以直接把函数作为实参使用,此时他会自动转化为指针。
bool lenCompare(const string &, const string &);
//第三个形参是函数类型,他会自动的转换成指向函数的指针
void useBigger(const string &s1, const string &s2,
bool pf(const string &, const string &));
//等价声明
void useBigger(const string &s1, const string &s2,
bool (*pf)(const string &, const string &));
int main()
{
string s1("a"), s2("b");
useBigger(s1, s2, lenCompare);//直接将函数作为实参使用,他会自动转换成指针
}
直接使用函数指针类型繁琐,可以用类型别名和decltype简化使用函数指针的代码。
bool lenCompare(const string &, const string &);
//func和func2是函数类型,二者等价
typedef bool func(const string&, const string&);
typedef decltype(lenCompare) func2;
//funcP和funcP2是指向函数的指针,二者等价
typedef bool(*funcP)(const string&, const string&);
typedef decltype(lenCompare) *funcP2;//decltype只会返回函数类型,不会将其转化为指针,所以加*
//重新声明useBigger
void useBigger(const string&, const string&, func);//编译器自动将func表示的函数转化成指针
void useBigger(const string&, const string&, funcP2);
返回指向函数的指针:和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。但必须把返回类型写成指针形式,编译器不会自动的将函数返回类型当成对应指针类型处理。要想声明一个返回函数指针的函数,最简单的是使用类型别名。
using F=int(int*, int);//F是函数类型,不是指针
using PF=int(*)(int*, int);//PF是函数指针类型
PF f1(int);//✔,f1返回指向函数的指针
F f1(int);//✖,F是函数类型,f1不能返回一个函数
F *f1(int);//✔,显式指定返回类型是指向函数的指针
/**
* 由内向外,f1有形参,所以f1是函数。f1前面有*,
* 所以返回一个指针;指针类型本身也包含形参,
* 因此指针指向函数,该函数的返回类型是int
*/
int (*f1(int))(int*, int);//不用别名声明返回函数指针
//使用尾置返回类型方式声明一个返回函数指针的函数
auto f1(int) -> int (*)(int*, int);
将auto和decltype用于函数指针类型:若我们明确知道返回的函数是哪一个,就可以使用decltype简化书写函数指针返回类型的过程。
string::size_type sumLen(const string &, const string &);
string::size_type largeLen(const string &, const string &);
//根据其形参的取值,getFcn函数返回指向sumLen或largeLen的指针
decltype(sumLen) *getFcn(const string&);
六. 类
类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,即:类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要定义一个抽象数据类型。在抽象数据类型(封装数据成员)中,由类的设计者负责考虑类的实现过程;而使用该类的程序员只需抽象思考类型做了什么,无需了解类型的工作细节。
1. 定义抽象数据类型
①名词介绍
成员函数:被定义为类的一部分的函数,也被称为方法。对象.方法:运算符进行调用。定义和声明成员函数和普通函数差不多。成员函数声明必须在类内部,它的定义在类内外都可。作为接口组成部分的非成员函数add,read,print,他们的定义和声明都在类外部。
执行加法和IO的函数不作为成员,我们将其定义为普通函数;执行复合赋值运算的函数是成员函数。
第一章第6节。
②定义Sales_data类
//销售数据类
//定义在类内部的函数是隐式的inline函数,定义在外部默认不内联
struct Sales_data{
string isbn() const
{
return bookNo;
//return this->bookNo;
}
Sales_data& combine(const Sales_data&);
double avg_price() const;
string bookNo;
unsigned units_sold={0};
double revenur=0.0;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
ostream &print(ostream&, const Sales_data&);
istream &read(istream&, Sales_data&);
Sales_data total;
定义成员函数:尽管所有成员必须在类内部声明,但是成员函数体可在类外也可在类内。对于Sales_data类来说,isbn函数定义在了类内, combine和ave_price定义在类外。
引入this:total.isbn():我们使用点运算符来访问total对象的isbn成员,然后调用它。成员函数通过一个名为this的额外隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。
例如:total.isbn():则编译器把total地址传给isbn的隐式形参this。
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无需通过成员访问运算符来做到这一点,因为this正是指的这个对象。任何对类成员的直接访问都被看做this的隐式调用。即:当isbn使用bookNo时,它将隐式使用this指向的成员,像书写了this->bookNo一样(当然isbn中也可以这样写)。
因为this的目的总是指向这个对象,所以this是一个常量指针,不允许修改this中保存的地址。
-
引入const成员函数:isbn函数的另外一个关键之处是紧随参数列表之后的const关键字,这里,其作用是修改隐式this指针的类型。
默认情况下,this的类型是指向类类型非常量版本的常量指针。在Sales_data中,this类型是Sales_data *const。尽管this是隐式的,但仍需遵循初始化规则。意味着(默认情况下), 我们不能把this绑定到一个常量对象上(const int i=0;
int *const p=&i;//✖,非常量无法指向常量)。这一情况使得我们无法在一个常量对象上,调用普通成员函数。
若isbn是一个普通函数且this是一个普通的指针参数,则我们应该把this声明成const Sales_data *const。毕竟,在isbn的函数体内不会改变this所指的对象,所以把this设为指向常量的指针有助于提高函数灵活性(此时指向常量对象,也可指向非常量)
但this不会出现在参数列表,所以c++允许把const放在成员函数的参数列表后,表示this是一个指向常量对象的常量指针。这种使用const的成员函数被称作常量成员函数。(isbn可以读取调用它的对象的数据成员,但是不能修改)
常量对象、常量对象的引用或指针都只能调用常量成员函数。
-
类作用域和成员函数:类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域内,因此isbn中国用到的bookNo就是Sales_data的数据成员
即使bookNo定义在isbn后, isbn仍能访问bookNo。因为编译器分两步处理类:首先编译成员(变量和函数)的声明,然后才轮到成员函数体。因此成员函数体可随意使用类中其他成员,而无需在意次序。
-
在类的外部定义成员函数:像其他函数一样,当我们在类外部定义成员函数时,成员函数的定义必须与它的声明匹配。若成员被声明为常量成员函数,则在形参列表后要指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名。
//销售数据类
//定义在类内部的函数是隐式的inline函数
struct Sales_data{
string bookNo;
unsigned units_sold={0};
double revenue=0.0;
string isbn() const
{
return bookNo;
}
Sales_data& combine(const Sales_data&);
double avg_price() const;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
ostream &print(ostream&, const Sales_data&);
istream &read(istream&, Sales_data&);
//类外定义成员函数
/**
* revenue, units_sold隐式使用了this调用成员
* @return
*/
double Sales_data::avg_price() const
{
if(units_sold) return revenue/units_sold;
return 0;
}
定义一个返回this对象的函数:combine设计类似于符合赋值运算符+=,调用该函数的对象代表的是赋值左侧的运算对象,右侧运算对象则通过显示的实参传入函数。
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
int main()
{
Sales_data total, trans, total2;
total2=total.combine(trans);//更新total的当前值,并绑定到total2
}
③定义类相关的非成员函数
l类的作者常定义一些辅助函数,比如add, read, print。尽管这些函数定义的操作从概念上是属于接口的组成部分,但他们实际上不属于类本身。
若函数在概念上属于类但是不定义在类中,则它一般应与类声明(非定义)在同一头文件内。
定义read和print函数:
/**
* read函数从给定流中将数据读入指定对象,prInt函数将
* 给定对象的内容打印到给定的流
*
* read和print分别接受一个IO类型引用作为参数,因为IO属于无法拷贝
* 的类型,所以只能通过引用传递它们。因为读写会改变流内容,所以是
* 普通引用
*
* print不换行,而是由用户决定
*/
istream &read(istream &is, Sales_data &item)
{
double price=0;
is>>item.bookNo>>item.units_sold>>price;
item.revenue=price*item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os<<item.isbn()<<" "<<item.units_sold;
return os;
}
定义add函数:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
//销售数据类
//定义在类内部的函数是隐式的inline函数
struct Sales_data{
string bookNo;
unsigned units_sold={0};
double revenue=0.0;
string isbn() const
{
return bookNo;
}
Sales_data& combine(const Sales_data&);
double avg_price() const;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
ostream &print(ostream&, const Sales_data&);
istream &read(istream&, Sales_data&);
//类外定义成员函数
/**
* revenue, units_sold隐式使用了this调用成员
* @return
*/
double Sales_data::avg_price() const
{
if(units_sold) return revenue/units_sold;
return 0;
}
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
/**
* read函数从给定流中将数据读入指定对象,prInt函数将
* 给定对象的内容打印到给定的流
*
* read和print分别接受一个IO类型引用作为参数,因为IO属于无法拷贝
* 的类型,所以只能通过引用传递它们。因为读写会改变流内容,所以是
* 普通引用
*
* print不换行,而是由用户决定
*/
istream &read(istream &is, Sales_data &item)
{
double price=0;
is>>item.bookNo>>item.units_sold>>price;
item.revenue=price*item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os<<item.isbn()<<" "<<item.units_sold;
return os;
}
Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum=lhs;//将lhs的数据成员拷贝给sum,这样lhs不会被修改
sum.combine(rhs);//将rhs数据加到sum
return sum;
}
int main()
{
}
④构造函数
每个类都分别定义了它对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务时初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。和其他函数不同的是,构造函数没有返回类型;除此之外类似于其他函数,构造函数也有一个参数列表和函数体。类可以包含多个构造函数,和其他重载函数一样,不同的构造函数必须在参数数量或类型上有所区别。不同于其他成员函数,构造函数不能被声明为const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其‘常量’属性。因此,构造函数在const对象的构造过程中可以向其写值。
合成的默认构造函数:上述Sales_data类并没有定义任何构造函数,可是之前使用仍能正确运行(Sales_data total)。我们没有为这些对象提供初始值,因此他们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参。
类没有显式地定义构造函数,那么编译器会为我们隐式地定义一个默认构造函数。
编译器创建的构造函数又被称为合成的默认构造函数。对于,大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:若存在类内的初始值,用它来初始化成员,否则默认初始化该成员(units_sold和revenue用给的值初始化,bookNo默认初始化为空字符串)
-
某些类不能依赖于合成的默认构造函数:合成的默认构造函数只适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数,有以下三个原因。
[1] 编译器只有在发现类不包含任何构造函数时,才会生成默认构造函数。一旦定义了其他一些构造函数,除非我们再定义默认构造函数,则将没有默认构造函数
[2] 对于某些类说,合成的默认构造函数可能执行错误的操作。若定义在块中的内置类型或复合类型(数组和指针)的对象被默认初始化,则他们的值是未定义的。因此,含有内置类型或复合类型成员的类应在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。
[3] 有时编译器不能为某些类合成默认构造函数。例如,类中包含一个其他类类型成员,且这个成员没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类,我们必须自定义默认构造函数,否则将无默认构造函数可用。
定义Sales_data的构造函数:
struct Sales_data{
//新增的构造函数
/**
* =default的含义:
* 对于下面的构造函数,因为它不接受任何实参,所以它是一个默认构造函数。
* 我们定义这个构造函数的目的是,我们需要默认构造函数,又需要其他构造函数
*
* 在C++11中,如果我们需要默认行为,可以通过在参数列表后面写上‘=default’
* 来要求编译器生产构造函数。其中=default可以和声明出现在类内部,也可以在
* 类外部。和其他成员函数一样,在类内部默认内联, 在类外部默认不内联。
*
* 这个默认构造函数之所以有效是因为,提供了类内初始值,或执行默认初始化。若不支持类内初始值,
* 则应用构造函数初始值列表来初始化每个类成员
*/
Sales_data()=default;
/**
* 构造函数初始值列表:
* 这两个定义出现了两个新部分,即:冒号和冒号与花括号之间的代码。
* 其中花括号定义了函数体。
*
* 我们把新出现的部分称为构造函数初始值列表,它负责为新创建的对象
* 的一个或几个数据成员赋值。构造函数初始值是成员名字的一个列表,
* 每个名字后紧跟括号括起来的(或者在花括号内的)成员初始值,通过
* 逗号分隔
*
* 对于第一个,没有提供另外两个的初始值。则将同合成默认构造函数的方式
* 隐式初始化(类内值,默认初始化)。
*/
Sales_data(const string &s): bookNo(s) {}
Sales_data(const string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue{p*n} {}
/**
* 在类外部定义构造函数
*/
Sales_data(istream &);
/**
* 成员
*/
string isbn() const
{
return bookNo;
}
Sales_data &combine(const Sales_data&);
double avg_price() const;
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
//指明属于哪个类
Sales_data::Sales_data(istream &is)
{
read(is, *this);//read函数从is读入,然后存入this对象中
}
int main()
{
}
⑤拷贝、赋值和析构
除了定义类的对象如何初始化外,类需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝,比如:我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用了赋值运算符时会发生对象赋值操作。当对象不再存在时,执行销毁操作,局部对象会在块结束时销毁,vector(数组)x销毁时,其内部存储的对象也会被销毁。
若我们不主动定义这些操作,则编译器会替我们合成他们。
某些类不能依赖于合成的版本:尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是对于某些类来说,合成的版本无法正常工作。特别,当类需要分配类对象之外的资源时,合成版本常常会失效。
很多需要动态内存的类能使用vector对象或string对象管理必要的存储空间,能避免分配和释放内存带来的复杂性。若类包含vector或者string成员,则拷贝、赋值和销毁的合成版本能正常工作。当对含有vector成员的对象执行拷贝或赋值操作时,vector类会设法拷贝、赋值成员中的元素。当这样的对象被销毁时,将销毁vector对象,即依次销毁每个元素。与string类似。
2. 访问控制与封装
目前来说,我们已经为类定义了接口,但没有任何机制强制用户使用这些接口。类还没有被封装,即用户可以直达Sales_data对象内部并且控制它的具体实现细节。在C++中,我们使用访问说明符加强类的封装性:
public:定义在该说明符后的成员在整个程序内可被访问,public成员定义类的接口。
private:定义在该说明符后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,priveate部分封装了类的实现细节。
一个类可以包含0个或多个访问说明符,且对某个访问说明符能出现多少次也无规定。每个访问符指定接下来成员的访问级别,直到下一个访问符或到类尾为止。
class Sales_data
{
public:
Sales_data()=default;
Sales_data(const string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) {}
Sales_data(const string &s):bookNo(s) {}
Sales_data(istream);
string isbn()const{return bookNo;}
Sales_data &combine(const Sales_data&);
private:
double avg_price()const
{
return 0;
}
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
使用class或struct关键字:我们可以用两个关键字中的任何一个定义类。唯一区别是两者默认访问权限不同。
类可以在第一个访问说明符前定义成员,对于这种成员的访问权限依赖于定义类的方式。若使用struct关键字,则定义在第一个访问说明符之前的是public;相反,若使用class关键字,则这些成员是private的。
①友元
Sales_data的数据成员是private的,则read、print、add函数无法正常编译了。因为这几个函数尽管是类的接口的一部分,但它们不是类的成员。
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数称为它的友元。若类想把其他函数作为它的友元,只需增加一条以friend关键字开始的函数声明语句即可
友元声明只能出现在类定义内部,但是在类内出现的具体位置不限。友元不是类成员,也不受它所在区域访问控制级别约束。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Sales_data
{
//为Sales_data的非成员函数做友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
friend ostream &print(ostream&, const Sales_data&);
friend istream &read(istream&, Sales_data&);
public:
Sales_data()=default;
Sales_data(const string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) {}
Sales_data(const string &s):bookNo(s) {}
Sales_data(istream);
string isbn()const{return bookNo;}
Sales_data &combine(const Sales_data&);
private:
double avg_price()const
{
return 0;
}
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
ostream &print(ostream&, const Sales_data&);
istream &read(istream&, Sales_data&);
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
istream &read(istream &is, Sales_data &item)
{
double price=0;
is>>item.bookNo>>item.units_sold>>price;
item.revenue=price*item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os<<item.isbn()<<" "<<item.units_sold;
return os;
}
Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
Sales_data sum=lhs;//将lhs的数据成员拷贝给sum,这样lhs不会被修改
sum.combine(rhs);//将rhs数据加到sum
return sum;
}
int main()
{
}
友元的声明:友元的声明仅仅指定了访问的权限,而非通常意义上的函数声明。若希望类的用户能够调用某个友元函数,需要在友元声明外再专门对函数进行一次声明。
为了使友元对类的用户可见,通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,Sales_data头文件应该为read、print、add提供独立的声明(除了类内部的友元声明之外)
一些编译器允许友元函数无外部声明就使用它,但是最好提供独立的外部函数声明。
3. 类的其他特性
①类成员再探
为了展示新特性,我们再定义一对相互关联的类:Screen和Window_mgr
定义一个类型成员:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Screen{
public:
//类可以自定义某种类型在类中的别名,这个别名存在访问限制,同其他名字一样
//用来定义类型的成员必须先定义后使用,后面会解释
typedef string::size_type pos;
//using pos = string::size_type;
Screen()=default;
Screen(pos ht, pos wd, char c):high(ht), width(wd),
contents(ht*wd, c) {}//contents被初始化为连续ht*wd个c组成的字符串
char get() const//隐式内联, 读取光标处内容
{
return contents[cursor];
}
inline char get(pos ht, pos wd) const;//显示内联
Screen &move(pos r, pos c);//能在之后被设置为内联
private:
pos cursor=0;
pos high=0, width=0;
string contents;
};
/**
* 在类外部定义内联
*/
inline Screen &Screen::move(pos r, pos c)
{
pos row=r*width;//计算行的位置
cursor=row+c;
return *this;//以左值方式返回对象
}
char Screen::get(pos r, pos c) const//在类内以经被声明为了内联
{
pos row=r*width;//计算行的位置
return contents[row+c];//返回给定列字符
}
int main()
{
}
重载成员函数:同非成员函数的重载。
int main()
{
//上面的Screen类
Screen myScreen;
char ch=myScreen.get();//调用Screen::get()
ch=myScreen.get(0, 0);//调用Screen::(pos, pos)
}
可变数据成员:有时我们希望修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。
一个可变数据成员永远不会是const, 即使它是const对象(const成员函数让this可接受常量对象)的成员。因此,一个const成员函数可以改变一个可变成员的值。我们可以为Screen添加一个名为access_ctr的可变成员,来统计Screen每个成员函数被调用多少次。
class Screen{
public:
void some_member() const;
private:
mutable size_t access_ctr;
};
void Screen::some_member() const {
++access_ctr;
}
类数据成员的初始值:在定义好Screen类后,我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Screen{
public:
//类可以自定义某种类型在类中的别名,这个别名存在访问限制,同其他名字一样
//用来定义类型的成员必须先定义后使用,后面会解释
typedef string::size_type pos;
//using pos = string::size_type;
Screen()=default;
Screen(pos ht, pos wd, char c):high(ht), width(wd),
contents(ht*wd, c) {}//contents被初始化为连续ht*wd个c组成的字符串
char get() const//隐式内联, 读取光标处内容
{
return contents[cursor];
}
inline char get(pos ht, pos wd) const;//显示内联
Screen &move(pos r, pos c);//能在之后被设置为内联
private:
pos cursor=0;
pos high={0}, width=(0);
string contents;
};
/**
* 在类外部定义内联
*/
inline Screen &Screen::move(pos r, pos c)
{
pos row=r*width;//计算行的位置
cursor=row+c;
return *this;//以左值方式返回对象
}
char Screen::get(pos r, pos c) const//在类内以经被声明为了内联
{
pos row=r*width;//计算行的位置
return contents[row+c];//返回给定列字符
}
class Window_mgr{
private:
vector<Screen> screens{Screen(24, 80, ' ')};//创建了单元素vector对象
};
int main()
{
}
②返回*this的成员函数
添加set的返回引用的函数。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Screen{
public:
//类可以自定义某种类型在类中的别名,这个别名存在访问限制,同其他名字一样
//用来定义类型的成员必须先定义后使用,后面会解释
typedef string::size_type pos;
//using pos = string::size_type;
Screen()=default;
Screen(pos ht, pos wd, char c):high(ht), width(wd),
contents(ht*wd, c) {}//contents被初始化为连续ht*wd个c组成的字符串
char get() const//隐式内联, 读取光标处内容
{
return contents[cursor];
}
inline char get(pos ht, pos wd) const;//显示内联
Screen &move(pos r, pos c);//能在之后被设置为内联
//新增,返回引用的函数
Screen &set(char);
Screen &set(pos, pos, char);
private:
pos cursor=0;
pos high={0}, width=(0);
string contents;
};
/**
* 在类外部定义内联
*/
inline Screen &Screen::move(pos r, pos c)
{
pos row=r*width;//计算行的位置
cursor=row+c;
return *this;//以左值方式返回对象
}
char Screen::get(pos r, pos c) const//在类内以经被声明为了内联
{
pos row=r*width;//计算行的位置
return contents[row+c];//返回给定列字符
}
class Window_mgr{
private:
vector<Screen> screens{Screen(24, 80, ' ')};//创建了单元素vector对象
};
//新增, 返回引用,即对象本身,而非副本
inline Screen &Screen::set(char c) {
contents[cursor]=c;//设当前光标位置为新值
return *this;
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r*width+col]=ch;//设置给定位置新值
return *this;
}
int main()
{
Screen myScreen;
myScreen.move(4, 0).set('#');
//等价于上面
myScreen.move(4, 0);
myScreen.set('#');
//若我们返回的是Screen而非Screen&
Screen temp=myScreen.move(4, 0);//对返回值进行拷贝
temp.set('#');//set只能改变副本,而不能改变myScreen
}
从const成员函数返回*this:添加display操作,它负责打印Screen内容。为了让其出现在move, set序列中,我们将其设置返回Screen&。
显示一个Screen并不需要改变其内容,所以令display为一个const成员。此时,this将是一个指向const的指针而*this是const对象。由此推断, display的返回类型应该是const Screen&。但是若真的令其返回对象时上面的,我们无法将其嵌入一组动作的序列中:
Screen myScreen;
//若display返回常量引用,则调用set将会发生错误
myScreen.display(cout).set('*');
即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于display的const版本返回的是常量引用,我们无权set一个常量对象。
-
基于const的重载:
通过区分成员函数是否是const的,我们可以对其进行重载,同前面指针参数是否指向const一样。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但非常量版本是一个更好的匹配。
在下面,我们将定义do_display的私有成员,它负责打印screen的实际工作。所有display操作都将调用这个函数,并返回执行操作的对象
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Screen{
public:
//类可以自定义某种类型在类中的别名,这个别名存在访问限制,同其他名字一样
//用来定义类型的成员必须先定义后使用,后面会解释
typedef string::size_type pos;
//using pos = string::size_type;
Screen()=default;
Screen(pos ht, pos wd, char c):high(ht), width(wd),
contents(ht*wd, c) {}//contents被初始化为连续ht*wd个c组成的字符串
char get() const//隐式内联, 读取光标处内容
{
return contents[cursor];
}
inline char get(pos ht, pos wd) const;//显示内联
Screen &move(pos r, pos c);//能在之后被设置为内联
Screen &set(char);
Screen &set(pos, pos, char);
//新增重载打印
/**
* 根据对象是否是const重载了display函数
*/
Screen &display(ostream &os)
{
do_display(os);
return *this;
}
/**
* 和之前相同,当一个成员调用另一个成员时,this指针在其中隐式的传递
* 当display调用do_display时,它的this隐式传递给do_display
* 而当display的非常量版本调用do_display时,它的this指针从指向
* 非常量的指针转换成指向常量的指针(do_display可以指向常量也可以指向非常量对象)
*
* 当do_display(无返回值)完成后,display函数将各自返回解引用this所指的对象
* 在非常量版本中, this返回普通引用。而,const版本返回常量引用
*/
const Screen &display(ostream &os) const
{
do_display(os);
return *this;
}
private:
pos cursor=0;
pos high={0}, width=(0);
string contents;
//新增实际打印函数, 即使非常量display,但实际操作是由常量do_display完成
void do_display(ostream &os) const
{
os<<contents;
}
};
/**
* 在类外部定义内联
*/
inline Screen &Screen::move(pos r, pos c)
{
pos row=r*width;//计算行的位置
cursor=row+c;
return *this;//以左值方式返回对象
}
char Screen::get(pos r, pos c) const//在类内以经被声明为了内联
{
pos row=r*width;//计算行的位置
return contents[row+c];//返回给定列字符
}
class Window_mgr{
private:
vector<Screen> screens{Screen(24, 80, ' ')};//创建了单元素vector对象
};
inline Screen &Screen::set(char c) {
contents[cursor]=c;//设当前光标位置为新值
return *this;
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r*width+col]=ch;//设置给定位置新值
return *this;
}
int main()
{
//当我们在某个对象上调用display时,该对象是否是const决定了
//应该调用哪个display版本
Screen myScreen(5, 3, ' ');
const Screen blank(5, 3, ' ');
myScreen.set('#').display(cout);//调用非常量版本
blank.display(cout);//调用常量版本
}
③类类型
每个类定义了唯一的类型。对于两个类来说,即使他们的程艳完全一样,这两个类也是完全不同的类型。
我们可以把类名作为类型名字使用,从而直接指向类类型。或者,也可以把类名跟在关键字class或struct后。
Sales_data item1;
class Sales_data item1;//两条等价声明语句
类的声明:像可以把函数的声明和定义分离开一样,我们也可以仅声明而暂时不定义它。
class Screen;
这种声明有时被称作前向声明,它向程序中引入Screen并指明其是一种类类型。对于类类型Screen类型来说,在他声明后定以前是一种不完全类型,即:Screen是一个类类型,但是不知道它包含哪些成员。
不完全类型只能在有限场景下使用:可以定义指向这种类型的指针或引用;也可以声明(但是不能定义)以不完全类型作为参数或返回值类型的函数。
类没有定义,则编译器不知道存储这种类型的对象需要多少空间。因为只有类全部完成后才算被定义,所以一个类的成员类型不能是该类自己。然而,一个类的名字出现过后,就被认为是声明过了(未定义),因此类允许包含指向它自身类型的引用或指针。
class Link_screen{
Screen window;
Link_screen *next;
Link_screen *prev;
}
④友元再探
之前的Sales_data类将三个普通的非成员函数定义为了友元。类还可以把其他类定义为友元,也可以把其他类(之前已经定义过的)的成员函数定义成友元,此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
-
类之间的友元关系:
以window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据。例如,假设我们需要为window_mgr添加一个名为clear的成员,它负责把一个指定的Screen的内容都设为空白。clear需要访问Screen的私有成员;想要令这种访问合法,Screen需要把Window_mgr指定成它的友元
友元关系不具有传递性,若Window_mgr有他自己的友元,则这些友元不具有访问Screen的特权
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Screen{
public:
//新增
//Window_mgr可以访问Screen的私有部分
friend class Window_mgr;
//类可以自定义某种类型在类中的别名,这个别名存在访问限制,同其他名字一样
//用来定义类型的成员必须先定义后使用,后面会解释
typedef string::size_type pos;
//using pos = string::size_type;
Screen()=default;
Screen(pos ht, pos wd, char c):high(ht), width(wd),
contents(ht*wd, c) {}//contents被初始化为连续ht*wd个c组成的字符串
char get() const//隐式内联, 读取光标处内容
{
return contents[cursor];
}
inline char get(pos ht, pos wd) const;//显示内联
Screen &move(pos r, pos c);//能在之后被设置为内联
Screen &set(char);
Screen &set(pos, pos, char);
/**
* 根据对象是否是const重载了display函数
*/
Screen &display(ostream &os)
{
do_display(os);
return *this;
}
/**
* 和之前相同,当一个成员调用另一个成员时,this指针在其中隐式的传递
* 当display调用do_display时,它的this隐式传递给do_display
* 而当display的非常量版本调用do_display时,它的this指针从指向
* 非常量的指针转换成指向常量的指针(do_display可以指向常量也可以指向非常量对象)
*
* 当do_display(无返回值)完成后,display函数将各自返回解引用this所指的对象
* 在非常量版本中, this返回普通引用。而,const版本返回常量引用
*/
const Screen &display(ostream &os) const
{
do_display(os);
return *this;
}
private:
pos cursor=0;
pos high={0}, width=(0);
string contents;
//即使非常量display,但实际操作是由常量do_display完成
void do_display(ostream &os) const
{
os<<contents;
}
};
/**
* 在类外部定义内联
*/
inline Screen &Screen::move(pos r, pos c)
{
pos row=r*width;//计算行的位置
cursor=row+c;
return *this;//以左值方式返回对象
}
char Screen::get(pos r, pos c) const//在类内以经被声明为了内联
{
pos row=r*width;//计算行的位置
return contents[row+c];//返回给定列字符
}
inline Screen &Screen::set(char c) {
contents[cursor]=c;//设当前光标位置为新值
return *this;
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r*width+col]=ch;//设置给定位置新值
return *this;
}
class Window_mgr{
public:
//窗口中,每个屏幕的编号
using ScreenIndex=vector<Screen>::size_type;
//将指定的屏幕置为空白
void clear(ScreenIndex);
private:
vector<Screen> screens{Screen(24, 80, ' ')};//创建了单元素vector对象
};
//新增
void Window_mgr::clear(ScreenIndex i) {
Screen &s=screens[i];
s.contents=string (s.high*s.width, ' ');
}
int main()
{
}
令成员函数作为友元:
可以值为clear提供访问权限。当把一个成员函数声明成友元时,必须指定其所属的类。
要想令某个成员函数作为友元,必须仔细组织程序结构,以满足声明和定义彼此依赖的关系(上面的类友元不用)。在这个例子中必须按下面:
[1] 首先定义Window_mgr类,其中声明clear函数,但是不能定义它(会访问Screen私有)。在clear使用Screen的成员前必须先声明Screen
[2] 接下来定义Screen,包括对于clear的友元声明
[3] 最后定义clear,此时他才可以使用Screen成员
class Screen{
//Window_mgr::clear必须在Screen之前被声明
friend void Window_mgr::clear(ScreenIndex);
}
函数重载和友元:
尽管重载函数的名字相同,但他们仍然是不同的函数。因此,如果一个类想把一组重载函数声明它的友元,他需要对这组函数中的每一个分别声明:
//重载的storeOn函数
extern ostream& storeOn(ostream &, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);
class Screen{
//storeOn的ostream版本能访问Screen对象的私有部分
friend ostream& storeOn(ostream &, Screen &);
};
//但是BitMap版不能访问Screen私有成员
友元声明和作用域:
类和非成员函数的声明不是必须在他们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式的假定改名字在当前的作用域中是可见的。然而,友元本身并不一定真的声明在当前作用域中。
甚至就算在类的内部定义该函数,也必须在类的外部提供相应的声明从而使得函数可见。即:即使我们仅仅是用声明友元的类的成员调用该友元函数,他也必须是被声明过得。
友元声明的作用是影响访问权限,它本身并非普通意义上的声明。
struct X{
friend void f(){/*友元函数可以定义在类的内部*/}
X() {f();}//✖, f还没有被声明
void g();
void h();
};
void X::g() {return f();}//✖,f还没有声明
void f();//声明那个定义在X中的函数
void X::h() {return f();}//✔,f被声明在作用域中了
4. 类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或指针使用成员访问符来访问。对于类类型成员则使用作用域运算符访问。无论哪种情况,跟在运算符后的必须是对应类的成员。
作用域和定义在类外部的成员:
一个类就是一个作用域,所以当在类外部定义成员函数时必须同时提供类名和函数名,在类的外部,成员的名字被隐藏起来了。
一但遇到类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员无需再次授权了。比如类外定义成员函数
void Window_mgr::clear(ScreenIndex i) {
Screen &s=screens[i];
s.contents=string (s.high*s.width, ' ');
}
Window_mgr表示我们正在处于其作用域内,可直接用ScreenIndex
另一方面函数的返回值类型通常在函数名前。因此成员函数定义在类外时,返回类型中使用的名字都位于类的作用域外。这是,返回类似必须声明它是哪个类的成员。如下addScreen
class Window_mgr{
public:
//窗口中,每个屏幕的编号
using ScreenIndex=vector<Screen>::size_type;
//将指定的屏幕置为空白
void clear(ScreenIndex);
//新增
ScreenIndex addScreen(const Screen &);
private:
vector<Screen> screens{Screen(24, 80, ' ')};//创建了单元素vector对象
};
void Window_mgr::clear(ScreenIndex i) {
Screen &s=screens[i];
s.contents=string (s.high*s.width, ' ');
}
//新增:首先处理返回类型,之后才进入Window_mgr的作用域
//因为返回类型出现在类名前,所以事实上他位于作用域外,所以需要指定作用域
Window_mgr::ScreenIndex
Window_mgr::addScreen(const Screen &s) {
screens.push_back(s);
return screens.size()-1;
}
①名字查找与类的作用域
到目前为止,名字查找(寻找与所用名字最匹配的声明的过程)的过程比较直接了当,如下:
[1] 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
[2] 如果没找到,继续查找外层作用域
[3] 如果最终没有找到匹配的声明,则程序报错
对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前例子不太明显。类的定义分两步处理:
[1] 首先,编译成员的声明.
[2] 直到类全部可见后才编译函数体。编译器处理完类中全部声明后才会处理成员函数的定义。
按照这两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以能使用类中定义的任何名字。若函数的定义和声明被同时处理,不得不在成员函数中使用那些已经出现的名字。
用于类成员声明的名字查找:
这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回值类型或参数列表中使用的名字,都必须在使用前确保可见。若某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。
/**
* 当编译器看到balance的声明语句时,会在Account类范围内找
* 对Money的声明,因为没有找到,所以会在外层作用域查找(该类前面声明过的东西)
* ,最终找到typedef...
*
* 另一方面,函数体在处理完类声明后才会处理,此时balance的bal在类内找到的是Money
* 类型的bal
*/
typedef double Money;
string bal;
class Account{
public:
Money balance()
{
return bal;
}
private:
Money bal;
};
类型名要特殊处理:
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过(因为匹配是从当前使用的位置向上查找块内)。然而在类中,若成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字(类是先处理所有声明,会造成歧义,不知道是类外还是类内定义的),即使编译器没报错。
typedef double Money;
class Account{
public:
Money balance()//使用外层作用域的Money
{
return bal;
}
private:
typedef double Money;//✖,不能重新定义Money
Money bal;
};
成员定义中的普通块作用域的名字查找:
[1] 首先,在成员函数内查找该名字的声明。和前面一样,只考虑名字使用前出现的声明
[2] 如果在成员函数内没有查找到,则在类内继续查找这时类所有成员都可以被考虑。
[3] 如果类内也没找到该名字的声明,在类定义(成员函数定义, 成员函数在外部定义时)之前的作用域内继续查找。
一般来说,不建议使用其他成员的名字作为某个成员的参数。不过为了更好理解名字解析过程,我们违反这个约定
int height;
class Screen{
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height){
cursor=width*height;
/**
* 首先在函数作用域中查找名字,因此上面的height是参数声明
*/
}
private:
pos cursor=0;
pos height={0}, width=(0);
string contents;
};
若想使用隐藏的同名成员:
void Screen::dummy_fcn(pos height) {
cursor=width*this->height;//成员height
cursor=width*Screen::height;//成员height
}
//最好是不重名
void Screen::dummy_fcn(pos ht) {
cursor=width*height;//成员height
}
类作用域之后,在外围的作用域中查找:
若编译器在函数和类的作用域都没有找到名字,它将接着在外围定义域查找。
在例子中类外的height被隐藏了,若想使用,可以使用显示的域作用符
void Screen::dummy_fcn(pos height) {
cursor=width * ::height;//全局height
}
在文件中名字的出现处对其进行解析:
当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。
int height;
class Screen{
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height=0;//隐藏了外层作用域中的height
};
Screen::pos verify(Screen::pos);//这里返回类型的作用域没有设置形参和函数体
void Screen::setHeight(pos var) {
//var 参数
//height 类的成员
//verify 全局函数
height= verify(var);
}
/**
* 全局函数verify在类定以前没出现过
* 但它在函数定以前出现了,所以可以使用
*/
5. 构造函数再探
①构造函数初始值列表
我们定义变量习惯立即初始化,而非先定义再赋值。前者直接初始化成员,后者先初始化再赋值(效率低)。
构造函数的初始值有时必不可少:
有时可以忽略初始化和赋值的差异,但成员是const或引用,必须将其初始化。
当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
class ConstRef{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
/**
* 同其他常量对象或引用,ci和ri必须被初始化
* 因此,若没有为他们提供构造函数初始值的话
* 将引发错误
*/
//✖, ci和ri必须被初始化
ConstRef::ConstRef(int ii) {
i=ii;
ci=ii;//✖,不能给const赋值
ri=i;//✖,ri没被初始化
}
/**
* 我们初始化const或引用类型的数据成员的唯一
* 机会就是通过构造函数初始值
* 下面是正确形式
*/
ConstRef::ConstRef(int ii) : i(ii), ci(ii), ri(i) {}
成员初始化的顺序:
构造函数初始值中每个成员只能出现一次。
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序
成员的初始化顺序与他们在类定义中出现的顺序一致。构造函数初始值列表中初始值的前后关系不会影响实际的初始化顺序
一般来说,初始化顺序没什么要求。但是如果一个成员是用另一个成员初始化的,那么这两个成员初始化顺序非常关键。
class X{
int i;
int j;
public:
//未定义的:i在j之前初始化,不能用j初始化i
X(int val):j(val), i(j) {}
//如果可能尽量用构造函数的参数作为初始值
//而不是使用同一对象的其他成员,这样我们不必考虑初始化顺序
X(int val, int val2):i(val), j(val2) {}
};
默认实参和构造函数:
若一个构造函数为所有参数都提供了默认实参,则它实际上定义了默认构造函数(逻辑上)。因为我们不用提供任何实参就能调用它。
class Sales_data{
friend istream &read(istream&, Sales_data&);
public:
string bookNo;
unsigned units_sold;
double revenue;
//有下面的构造函数,编译器不会再生成默认构造函数(无参)
//定义默认构造函数(逻辑上),其与只接受一个string实参的构造函数功能相同
//因为我们不提供实参也能调用下面的构造函数,所以该构造函数逻辑上等同于默认构造函数
Sales_data(string s="") : bookNo(s) {}//左侧s是默认实参为空,没有给实参时,将“”传给bookNo初始化
//其他构造函数与之前一样,第六章1④小节
Sales_data(string s, unsigned cnt, double rev):
bookNo(s), units_sold(cnt), revenue{rev*cnt} {}
};
②委托构造函数
C++11扩展了构造函数初始值功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把自己的一些(或者全部)职责委托给了其他构造函数。
和其他构造函数一样,一个委托构造函数也有一个成员初始值列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
/**
* 在这个Sales_data类中,除了一个构造函数外其他的都委托了他们的工作。
* 第一个构造函数接受三个实参,使用这些实参初始化数据成员。
*
* 我们定义默认构造函数令其使用三参数的构造函数完成初始化过程,她也无需执行别的任务,空{}
* 接受一个string的构造函数同样也委托三参数的版本
*
* 接受istream&的构造函数委托给了默认构造函数,默认构造函数又委托给了三参数
* 构造函数。当这些受委托的构造函数执行完后,接着执行istream&版本构造函数体内容,即
* read
*
* 当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。
* 在下面类中,受委托的构造函数体是空的。若不为空,则先执行这些代码,再将控制权
* 还给委托者的函数体。
*/
class Sales_data{
friend istream &read(istream&, Sales_data&);
public:
string bookNo;
unsigned units_sold;
double revenue;
//非委托构造函数使用对应的实参初始化成员
Sales_data(string s, unsigned cnt, double price):
bookNo(s), units_sold(cnt), revenue{price*cnt} {}
//其余构造函数全都委托给另一个构造函数
Sales_data(): Sales_data("", 0, 0) {}
Sales_data(string s): Sales_data(s, 0, 0) {}
Sales_data(istream &is) : Sales_data() {read(is, *this);}
};
③默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:
[1] 当我们在块作用域内不使用任何初始值定义一个非静态变量或数组时。
[2] 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
[3] 当类类型的成员没有在构造函数初始值列表中显示的初始化时。
值初始化在以下情况下发生:
[1] 数组初始化的过程中如果我们提供的初始值数量小于数组大小时。
[2] 当我们不使用初始值定义一个局部静态变量时
[3] 当我们通过书写形如T()的表达式显式的请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。
类必须包含一个默认构造函数以便在上述情况下使用。大多数情况非常容易判断
不那么明显的一种情况是类的某些数据成员缺少默认构造函数:
class NoDefault {
public:
NoDefault(const string &);
//还有其他成员,但没有别的构造函数了,此类无默认构造函数
};
struct A{
NoDefault my_mem;//my_meme默认是public的
};
A a;//✖, 不能为A合成构造函数,因为NoDefault没有默认构造函数
struct B {
B() {}//✖,b_member没有初始值, 且无法执行默认构造函数
NoDefault b_member;
};
实际中,默认构造函数不可缺少。
使用默认构造函数:
//下面的obj的声明可以正常编译
Sales_data obj();//✔,定义了一个函数而非对象
/**
* 但是当我们使用是会报错,因为obj是函数。
* 问题在于,我们想声明一个默认初始化对象,而obj是函数
*/
if(obj.isbn()==a.isbn())//✖,obj是函数
//✔,obj是个默认初始化对象
Sales_data obj;
④隐式的类类型转换
C++语言在内置类型之间定义几种自动转换规则。
同样的,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称为转换构造函数。
能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
在Sales_data类中,接受string的构造函数和接受istream构造函数分别定义
了从这两种类型向Sales_data隐式转换的规则 。
即:在需要使用Sales_data的地方,我们可以用string或istream替代
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Sales_data
{
public:
Sales_data()=default;
Sales_data(const string &s):bookNo(s) {}
Sales_data(istream &) {};
Sales_data &combine(const Sales_data&);
private:
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
int main()
{
Sales_data item;
string null_book="999";
/**
* 构造一个临时的Sales_data对象
* 该对象的units和rebenue为0,bookNo为null_book
*
* 这里我们用一个string实参调用了Sales_data的combine(const Sales_data&)
* 成员,这是合法的,编译器用给定的string自动创建了(通过构造函数)一个
* Sales_data对象。这个临时对象Sales_data被传给combine。
*/
item.combine(null_book);
}
只允许一步类类型转换:编译器只会自动地执行一步类型转换
int main()
{
Sales_data item;
/**
* ✖,这个执行了两步转换
* 把"999"转换成string
* 把string转换成Sales_data
*/
item.combine("999");
//下面是对的
item.combine(string("999"));//显式转换string,隐式转换为Sales_data
item.combine(Sales_data("999"));//隐式转换为string,显式转换为Sales_data
}
类类型转换不总是有效:是否需要从string到Sales_data的转换依赖于我们对用户使用该转换的看法。
抑制构造函数定义的隐式转换:在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止。
关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit的。只能在类内声明构造函数时使用该关键字,在类外部定义时不应重复。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Sales_data
{
public:
Sales_data()=default;
explicit Sales_data(const string &s):bookNo(s) {}
explicit Sales_data(istream &) {};
Sales_data &combine(const Sales_data&);
private:
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
//✖,explicit只允许出现在类内构造函数声明
explicit Sales_data::Sales_data(istream &is) {}
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
int main()
{
Sales_data item;
item.combine(string("999"));//✖,string构造函数是explicit的
item.combine(Sales_data("999"));//隐式转换为string,显式转换为Sales_data
}
explicit构造函数只能用于直接初始化:发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用exolicit构造函数
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Sales_data
{
public:
Sales_data()=default;
explicit Sales_data(const string &s):bookNo(s) {}
explicit Sales_data(istream &) {};
Sales_data &combine(const Sales_data&);
private:
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
int main()
{
string null_book("999");
Sales_data item1(null_book);//✔,直接初始化
Sales_data item2=null_book;//✖不能将explicit构造函数用于拷贝形式初始化过程
}
为转换显示地使用构造函数:尽管编译器不会将explicit的构造函数用于隐式的转换过程,但是我们可以使用这样的构造函数显式的强制转换。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Sales_data
{
public:
Sales_data()=default;
explicit Sales_data(const string &s):bookNo(s) {}
explicit Sales_data(istream &) {};
Sales_data &combine(const Sales_data&);
private:
string bookNo;
unsigned units_sold=0;
double revenue=0.0;
};
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold+=rhs.units_sold;//把rhs的成员加到this对象的成员上
revenue+=rhs.revenue;
return *this;//返回调用该函数的对象
}
int main()
{
string null_book("999");
Sales_data item;
item.combine(Sales_data(null_book));//✔,实参是显式构造的Sales_data对象
//第二个使用static_cast执行了显示的而非隐式的转换。用istream构造函数创建了一个Sales_data对象
item.combine(static_cast<Sales_data>(cin));//✔,static_cast可以使用explicit构造函数
}
标准库中含有显式构造函数的类:
接受一个单参数的const chat*的string构造函数不是explicit的
接受一个容量参数的vector构造函数是explicit的
⑤聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说他聚合的:
[1] 所有成员都是public的
[2] 没有定义任何构造函数
[3] 没有类内初始值
[4] 没有基类,也没有virtual函数
#include <iostream>
#include <vector>
#include <string>
using namespace std;
//聚合类
struct Data{
int ival;
string s;
};
int main()
{
/**
* 我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类
* 的数据成员
*
* 初始值的顺序必须与声明的顺序一致
*
* 与初始化数组元素规则一样。若初始值列表中的元素个数少于类的成员
* 数量,则靠后的成员被值初始化。初始值列表的元素个数不能超过类的
* 成员数量
*/
//val1.ival=0;val1.s=string("www");
Data val1={0, "www"};
}
显式地初始化类对象有以下三个缺点:
[1] 要求类成员都是public
[2] 将正确初始化的任务交给了用户(而非类的作者)。因为用户
[3] 添加或删除一个成员,所有初始化语句都要更新
⑥字面值常量类
constexpr函数的参数和返回值必须是字面值类型。除了算术类型、指针、引用外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数所有要求,他们是隐式const的。
数据成员都是字面值类型的聚合类是字面值常量类。若一个类不是聚合类,但他符合以下要求,则它也是一个字面值常量类:
[1] 数据成员都是字面值类型
[2] 类必须至少含有一个constexpr构造函数
[3] 若一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者若成员属于某种类型,则初始值必须使用成员自己的constexpr构造函数
[4] 类必须使用析构函数的默认定义,该成员赋值销毁类的对象。
constexpr构造函数:尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。constexpr构造函数可以声明成=default的形式或者删除函数的形式。否则,constexpr函数必既符合构造函数的要求(无返回值), 又符合constexpr函数的要求(他可能拥有的唯一可执行语句就是返回语句)。综上,constexpr构造函数体一般来说是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Debug {
public:
/**
* constexpr构造函数必须初始化所有数据成员,
* 初始值或者使用contexpr构造函数,
* 或者是一条常量表达式
*/
constexpr Debug(bool b=true) : hw(b), io(b), other(b) {}
constexpr Debug(bool h, bool i, bool o) :hw(h), io(i), other(o) {}
constexpr bool any() const
{
return hw||io||other;
}
void set_io(bool b) {io=b;}
void set_hw(bool b) {hw=b;}
void set_other(bool b) {hw=b;}
private:
bool hw;//硬件错误,而非iy错误
bool io;//io错误
bool other;//其他错误
};
int main()
{
//constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型
//io_sub的构造函数被标记为constexpr,并且参数 (false, true, false) 也是常量表达式,
//所以这条语句要求编译器在编译时完全构造io_sub对象。
constexpr Debug io_sub(false, true, false);//调试io
if(io_sub.any())//等价于if true
cerr<<"dsa"<<endl;
constexpr Debug prod(false);//无调试
if(prod.any())//等价于if false
cerr<<"sda"<<endl;
}
6. 类的静态成员
有时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。例如银行账户类的一个数据成员表示当前的基准率。我们希望基准率与类关联,而非与类的每个对象关联。从实现效率看没必要为每个对象存储利率,且希望一旦利率浮动,所有对象都能使用新值。
声明静态成员:我们通过在成员的声明之前加上关键字static使得其与类关联在一起。静态成员可以是public或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
/**
* 类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据
* 因此Account类对象包含两个数据成员:owner和amount。只存在一个interestRate对象
* 且他被所有Account对象共享
*
* 静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,
* 静态成员函数不能声明为const的,而且我们也不能在static函数体内使用
* this指针(不能使用非静态成员)。这一限制既适用于this的显式使用,也对调用非静态成员的隐式使用
* 有效
*/
class Account {
private:
string owner;
double amount;
static double interestRate;
static double initRate();
public:
void calculate()
{
amount+=amount*interestRate;
}
static double rate()
{
return interestRate;
}
static void rate(double);
};
int main()
{
}
使用类的静态成员:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Account {
private:
string owner;
double amount;
static double interestRate;
static double initRate();
public:
void calculate()
{
//成员函数不用通过作用域运算符就能直接使用静态成员
amount+=amount*interestRate;
}
static double rate()
{
return interestRate;
}
static void rate(double);
};
int main()
{
double r;
//使用作用域访问静态成员
r=Account::rate();
//虽然静态成员不属于某个对象
//但是我们仍能使用类的对象、引用或指针来访问静态成员
Account ac1;
Account *ac2=&ac1;
r=ac1.rate();
r=ac2->rate();
}
定义静态成员:既可以在类内部也可以在类外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Account {
private:
string owner;
double amount;
static double interestRate;
static double initRate();
public:
void calculate()
{
amount+=amount*interestRate;
}
static double rate()
{
return interestRate;
}
static void rate(double);
};
//类外部定义静态成员函数
void Account::rate(double newRate)
{
interestRate=newRate;
}
int main()
{
}
因为静态数据成员不属于类的任何一个对象,所以他们并不是在创建类的对象时被定义的。这意味着他们不是由类的构造函数初始化的。一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦他被定义,将一直存在于程序的整个声明周期内。在 C++ 中,静态数据成员需要在类外进行定义。只是声明而不定义的静态数据成员不会自动分配内存空间。在类外定义静态数据成员时,实际的内存空间才会被分配。
如果你只在类中声明了静态数据成员,但没有在类外进行定义,编译器会在链接阶段报错,提示找不到静态成员的定义。
定义静态数据成员的方式和在类外部定义成员函数差不多。需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Account {
private:
string owner;
double amount;
static double interestRate;
static double initRate();
public:
void calculate()
{
amount+=amount*interestRate;
}
static double rate()
{
return interestRate;
}
static void rate(double);
};
//类外部定义静态成员函数
void Account::rate(double newRate)
{
interestRate=newRate;
}
//定义静态数据成员interestRate对象
/**
* 和其他成员的定义一样,该静态成员
* 也可以访问类的静态私有成员
*/
double Account::interestRate=initRate();
int main()
{
}
静态成员的类内初始化:通常,类的静态成员不在类内初始化。但是,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以他们能用在所有适合于常量表达式的地方。比如,我们可以用一个初始化了的静态数据成员指定数组成员的维度
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Account {
private:
string owner;
double amount;
static double interestRate;
static double initRate();
static constexpr int period=30;//period是常量表达式
double daily_tbl[period];
public:
void calculate()
{
amount+=amount*interestRate;
}
static double rate()
{
return interestRate;
}
static void rate(double);
};
/**
* 如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况
* 则一个初始化的const或constexpr static不需要分别定义。
* 若我们将它用于值不能替换的场景中,则该成员必须有一条定义语句
*
* 例如,若period唯一用途是定义数组维度,则不需要在Account外专门定义
* period。此时,如果我们忽略了这条定义,可能会造成编译错误,因为程序
* 找不到该成员定义语句(静态成员在类创建时不会被定义)。比如,当需要把
* Account::period传递给一个接受const int&的函数时,必须定义period
*/
//若在类内提供了初始值,则成员的定义不能再指定一个初始值了
constexpr int Account::period;
int main()
{
}
静态成员能用于某些场景,而普通成员不能:静态成员独立于任何对象,因此在某些非静态数据成员可能非法的场景,静态成员却可以正常使用。比如,静态数据成员可以是不完全类型(声明但没定义), 而非静态数据成员只能声明成它所属类的指针或引用。
静态成员和非静态成员的另一个区别是,我们可以使用静态成员作为默认实参,而非静态成员不行,因为他的值本身属于对象的一部分,无法真正提供一个对象以便从中获取成员的值
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Bar {
public:
//
private:
static Bar mem1;//✔,静态成员可以是不完全类型
Bar *mem2;//✔,指针成员可以是不完全类型
Bar mem3;//✖,数据成员必须是完全类型
};
//静态成员作默认实参
class Screen {
public:
//bkground是一个在类中稍后定义的静态成员
Screen& clear(char =bkground);
private:
static const char bkground;
};