C++
基本概念
内联函数
- 内联函数在函数编译的时候会自动展开,在编译后就不存在了,和宏展开类似。
- 内联函数可以在头文件中定义,并且多次包含也不会出现重复定义错误。
- 内联函数就像是在编译时期的宏,在编译后的链接时期就不存在了。
- 需要在函数定义的时候添加一个 inline 关键字,在函数声明时使用是无效的。
- 内联函数不应该有声明,但是声明也不会报错。
- 将内联函数的定义和声明放在不同文件中会报错,准确的说是在链接的时候报错。所以一般是将内联函数的定义放在头文件中。
- 内联函数有两个作用,第一个就是消除函数调用时的开销,第二个就是取代带参数的宏。
默认参数
在创建函数时候可以给形参设置一个默认的参数,默认参数也可以使用表达式,默认参数只能在形参列表的最后,一旦某个形参有了默认值,那么他后面的形参也要有默认值。C++中规定,在给定的作用域中,只能指定一次默认参数。
定义函数处和声明函数处的默认参数不同,那么在编译的时候使用的是当前作用域的默认参数。同一个函数可以多次声明。
函数重载
C++允许多个函数有相同的名字,只要参数列表不同就行,这就是函数的重载(Function Overloading)参数列表又叫参数签名,包括参数的类型、参数的个数、参数的顺序,只要有一个不同就叫做参数列表不同。
函数重载的规则:
- 函数名称必须相同
- 参数列表必须不同
- 函数的返回类型可以相同也可以不同
- 仅仅返回类型不同不能作为函数重载的依据
C++在编译的时候会根据参数列表对函数进行重新命名,例如void Swap(int a, int b)
会被重命名为Swap_int_int
,void Swap(float x, float y)
会被重命名为Swap_float_float
。当发生函数调用的时候编译器会根据传入的实参来逐个匹配,选择对应的函数,如果匹配失败,编译器就会报错,这就叫重载决议。
函数重载仅仅是语法层面,本质上还是不同的函数,占用不同的内存。函数重载,参数类型过少或者过多都容易引起二义性错误。
extern “C” 大致有 2 种用法:
- 当仅修饰一句 C++ 代码时,直接将其添加到该行代码的开头即可;
- 如果用于修饰一段 C++ 代码,只需为 extern “C” 添加一对大括号{},并将要修饰的代码囊括到括号内即可。
类和对象
概念
类似创建对象的模板,一个类可以创建多个对象,每个对象都是类类型的一个变量;创建对象的过程也叫类的实例化。每个对象都是类的一个具体实例,拥有类的成员变量和成员函数。
与结构体一样,类只是复杂数据类型的声明,不占内存空间。
使用class关键字来定义类,类名的首字母一般大写,以便区分,{}内部的是类的成员变量和成员函数,统称为类的成员。类的定义后面有一个分号。
类只是一个模板,编译后不占内存空间,所以定义类的时候不能够对成员变量进行初始化,因为没有地方储存数据。只有在创建对象以后才会给成员变量分配内存,这时候才可以赋值。
在类中直接定义函数的时候可以不用在函数名前加上类名,但是当函数定义在类外的时候,就需要在函数名上加上类名,::
称为域解析符。
在类体中定义的成员函数会自动成为内联函数,在类体外定义的不会。内联函数会在编译时将函数调用的地方自动替换为函数体,这不是我们所期望的,所以建议在类体内部对函数进行声明,在类体外部进行定义。
类的访问权限
C++通过public、protected、private三个关键字来控制成员变量和成员函数的访问权限。分别是公有的、受保护的、私有的,称为成员访问限定符。所谓访问权限就是能不能使用该类的成员。
在类的内部,所有的成员函数和成员变量都可以相互访问,没有访问权限的限制。
在类的外部,只能通过对象访问成员,并且通过对象只能访问public属性的成员。
成员变量大多以m_
开头,这是约定成俗的写法,以m_
开头既可以一眼看出成员变量,又可以将成员函数中的形参名字区分。给成员变量赋值一般使用public函数,set和get,函数后面可以接变量名字。如果不写public和private,则默认为是private。在一个类体中,private和public可以出现多次,没有有效范围是知道另一个访问限定符的出现或者类体结束的时候。
除了set函数和get函数,在创建对象的时候还可以调用构造函数来初始化各个成员变量,不过构造函数只能够初始化各个成员变量的值,不能够给成员变量赋值。
类是创建对象的模板,不占用内存空间,不存在编译后的可执行文件中,但是对象却是实实在在的数据,需要内存来存储。对象被创建的时候会在栈区或者堆区中分配内存。不同对象的成员变量的值可能不同,所以需要单独的内存来存储。
==不同对象的成员函数的代码是一样的,可以将代码片段压缩成一份。==编译器会将对象的成员变量和成员函数分开存储,成员变量单独分配内存,但是成员函数共享一段函数代码。成员变量在栈区或者堆区分配内存,成员函数在代码区分配内存。对象的大小值只受成员变量的影响,和成员函数没有关系。类的内存分布也会有内存对齐的问题。
C++函数的编译
C语言的函数在编译的时候只是简单地加上一个下划线(不同的编译器实现有不同)。
C++在编译时,函数会根据所在的命名空间、所归属的类以及参数列表(参数签名)等信息来重新命名。形成一个新的函数名,这个新的函数名只有编译器知道,这个对函数重新命名的过程叫名字编码。名字编码的过程是可逆的,知道其中一个名字就可以推断出另一个名字。
注:如果想看到编译器产生的新的函数名,可以只声明函数而不定义函数,这样在调用函数的时候就会出现报错,从报错信息中可以看到新的函数名。
成员函数的调用
成员函数最终被编译成为了与对象无关的函数,如果函数中没有使用成员变量,那么直接调用即可。
如果成员函数中使用了成员变量,编译成员函数的时候需要额外添加一个参数,把当前对象作为指针传递进去,通过指针来访问成员变量。 这与我们在使用时想象的通过对象找函数相反,其实是通过函数找对象。
构造函数
有一种特殊的成员函数,他的名字和类名相同,没有返回值,不需要用户显式的调用,用户也不能调用,而是在创建对象的时候自动执行的函数,这个特殊的成员函数就是构造函数(Constructor)。
想要调用构造函数,就需要在创建对象的同时传递实参,并且实参由()
包围,和普通的函数调用非常类似。在栈上创建对象的时候,实参位于对象名的后面,Student stu("小明", 15, 92.5f);
。在堆上创建对象的时候,实现位于类名的后面,new Student("李华", 16, 96)
。构造函数是public属性的,否则会导致创建对象的时候无法调用
构造函数没有返回值,因为没有变量来接收返回值,所以无论是在定义还是声明,函数名前面都不能出现返回值类型并且函数体中也不能用return语句。构造函数是允许重载的,一个类可以由多个重载的构造函数,创建对象时根据传递的参数来判断调用那个构造函数。
构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象的时候一定会调用,如果有多个重载的构造函数那么在创建对象的时候,提供的实参必须和其中一个构造函数匹配。创建对象的时候只有一个构造函数会被调用。构造函数在工程中一般用来做一些初始化工作。如果没有定义构造函数,那么编译器会自动生成一个构造函数,这个默认的构造函数内部是空的。一个类必须有一个构造函数,要么是用户定义,要么是编译器自动生成,用户定义了构造函数,编译器就不会再自动生成。调用没有参数的构造函数的时候可以省略括号。
使用构造函数初始化还能够使用初始化列表,即定义构造函数的时候不用再函数体中对变量赋值,而是使用在函数首部和函数体之间加上一个冒号,后面紧跟m_name(name), m_age(age), m_score(score)
语句,该语句相当于函数体内部的m_name = name; m_age = age; m_score = score
。例如:
Student::Student(char *name, int age, float score): m_name(name), m_age(age), m_score(score){
//TODO:
}
使用初始化列表并没有效率上的优势,仅仅是为了书写和阅读方便。成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,只与成员变量在类的声明的顺序有关。在栈上分配内存,初始值是不确定的,在堆上分配内存,初始值是0。
构造函数初始化列表还有一个重要的作用就是初始化const成员变量,并且这也是唯一初始化const变量的方法。
析构函数
销毁对象时系统会自动调用一个函数来进行清理工作,比如释放内存,关闭打开的文件等,这个函数就是析构函数。析构函数(Destructor)也是特殊的成员函数,没有返回值,不需要显式的调用,也没法调用,在销毁对象的时候会自动调用。
构造函数的名字与类名相同,析构函数的名字是在类名前面加上一个~
。析构函数没有参数,不能被重载,所以一个类只能有一个析构函数。
C++中使用new和delete来分配和释放内存,与malloc、free最大的区别就是,用new分配内存的时候会调用构造函数,用delete释放内存的时候,会调用析构函数。
全局对象储存在内存区中的全局数据区,在程序执行结束时会调用这些对象的析构函数。局部对象储存在内存中的栈区,函数执行结束时会调用这些对象的析构函数。new创建的对象位于内存中的堆区,调用delete的时候调用析构函数,如果没有delete就不会执行析构函数。
C++对象数组
数组的每个元素都是对象,这样的数组称为对象数组。当构造函数有个多个元素的时候,数组的初始化列表要显式的包含对构造函数的调用。
C++成员对象
一个类的成员变量如果是一个类的对象,就称为成员对象。包含成员对象的类叫封闭类。创建封闭类的对象的时候,他包含的成员对象也需要被创建,这就会引发成员对象构造函数的调用。
封闭类对象生成时,先执行所有成员对象的构造函数,然后才执行封闭类自己的构造函数。成员对象构造函数的执行顺序和成员对象在类定义中的次序一致,与他们在构造函数初始化列表中出现的次序无关。
当封闭类对象消亡时,先执行封闭类的析构函数,然后在执行对象成员的析构函数,成员对象的析构函数的执行顺序,和析构函数的执行顺序相反,即先构造的后析构。
this指针
this是C++的一个关键字,也是一个const指针,指向当前对象,可以通过他访问当前对象的所有成员。this只能在类的内部使用,通过this可以访问类的所有成员包括public、protrcted、private属性的。
this虽然在类的内部,但是只有在对象被创建后才会给this赋值,并且这个赋值的过程是编译器自动完成的。this其实是成员函数的一个形参,在调用成员函数的时候,将对象的地址作为实参传递给this,不过this是在编译阶段有编译器自己添加到参数列表中的。
this作为形参,本质上是成员函数的局部变量,只能在成员函数的内部使用,并且只有在通过对象调用函数的时候才赋值。
成员函数在编译时会被编译成为与对象无关的普通函数,除了成员变量,会丢失所有的信息,所以编译的时候,要在成员函数中添加一个额外的参数,把当前对象作为指针传入,以此来关联成员变量和成员函数,这个额外的参数就是this。
静态成员变量
有时候希望多个对象之间共享数据,可以使用静态成员变量来实现多个对象共享数据的目标。静态成员变量是一种特殊的成员变量,使用关键字static来修饰。静态成员变量属于类,不属于某个具体的对象,即使创建多个对象,也是分配一份内存,所有的对象都是使用的这份内存中的数据。
static成员变量必须在类声明的外部初始化。静态成员变量在初始化时不能再加static,但是必须要有数据类型。
staic成员变量的内存既不是在声明类时分配,也不是在创建对象的时候分配,而是在类外初始化的时候分配。所以没有在类外初始化的static成员变量不能够使用。static成员变量既可以通过类来访问,也可以通过成员变量来访问。static成员变量不占用对象的内存,而是在所有对象之外开辟内存,即使不创建对象也可以访问。
static成员变量和普通static变量一样,都在内存分区中的全局数据区分配内存,到程序结束才释放。静态成员变量必须初始化,并且在类外初始化,初始化可以赋值也可以不赋值,默认赋值为0。
静态成员变量既可以通过对象名访问也可以通过类名来访问。需要遵循关键字访问权限限制。
静态成员函数
普通成员函数可以访问所有的成员,静态成员函数只能够访问静态成员。编译器在编译一个普通的成员函数的时候会给函数加上一个形参this,并把当前对象的地址传给this,所以普通成员函数只能够在创建对象后通过对象来调用。
静态成员函数可以直接通过类来调用,编译器不会为他加上一个this形参,不需要当前对象的地址。普通成员变量占用对象的内存,但是静态成员没有this指针,所以不知道指向那个对象,无法访问对象的成员变量。所以静态成员不能访问普通的成员变量,只能访问静态成员变量。
在C++中,静态成员函数的主要作用就是访问静态成员,静态成员函数可以通过对象和类来调用,但是一般是通过类来调用的。
const成员函数和成员变量
const成员变量初始化只有通过构造函数的初始化列表。
const成员函数可以使用类中的所有成员变量,但是不能修改他们的值。const成员函数也叫常成员函数,通常将get函数设置成const函数。
const成员函数和const成员变量需要在定义和声明处都加上const关键字,例如:int getage() const
。
函数开头的const用来修饰函数的返回值,表示返回值是const类型。函数头部结尾的const表示常成员函数,这种函数只能读取成员变量的值,而不能修改成员变量的值。
const对象
const修饰对象叫做常对象,常对象只能调用类的const 成员,
友元函数和友元类
借助友元(friend),可以使得其他类中的成员函数以及全局范围内的函数访问当前类的private成员。
友元函数,在当前类外定义的,不属于当前类的函数也可以在当前类中声明,但是要在前面加上friend关键字,这样就构成了友元函数,友元函数可以是不属于任何类的非成员函数,也可以是其他类的成员函数。
友元函数可以访问当前类中的所有成员,包括public、protected、private属性。友元函数不同于类的成员函数,在友元函数中不能直接访问类的成员,必须要借助对象来访问。
某些情况下,只要做好提前声明,就可以先试用类,但是类的提前声明的范围是有限的,只有在一个正式声明后才能用他去创建对象。因为创建对象是需要分配内存的,但是没有正式声明,编译器不知道要分配多大的内存。
一个函数可以声明被多个类声明为友元函数,这样就可以访问多个类中的private成员。
友元类中的所有成员函数都是另一个类的友元函数。
注:友元的关系是单向的,而不是双向的。友元的关系不能够传递。
类也是一种作用域,每个类都会定义自己的作用域,在类的作用域之外,普通成员只能通过对象访问,静态成员既可以通过对象访问,又可以通过类访问,但是typedef定义的类型只能通过类来访问。
struct和class区别
- class中的成员默认是private,struct中的成员是public。
- class继承默认是private继承,struct继承默认是public继承
- class可以使用模板,但是struct不能使用模板
字符串string
string是C++中的一个类,使用的时候需要包含头文件 <string>
。定义字符串没有初始化的话,编译器会默认给一个空字符串。
C++的字符串结尾没有结束标志 “\0”,如果要知道字符串的长度,可以使用string中提供的length()
,字符串返回的是正式长度而不是长度+1。
在实际编程中,有时候必须要使用c语言风格的字符串(例如打开文件时的路径),为此,string类为我们提供了一个转换函数c_str()
,该函数能够将string字符串转换为c风格的字符串,并且返回该字符串的const指针。
string path = "D:\\demo.txt";
FILE *fp = fopen(path.c_str(), "rt");
string类重载了输入输出运算符,可以像对待普通变量那样对待string变量,也就是用>>
进行输入,用<<
进行输入。并且对string类来说,可以使用+
和+=
运算符来直接拼接字符串,非常方便,不用再使用strcat()
、strcpy()
、malloc()
来拼接字符串。
string字符串的增删改查
插入:insert()
函数可以在string字符串中指定的位置插入另一个字符串:string& insert(size_t pos, const string& str);
。
- pos表示要插入的位置,也就是下标;str表示要插入的字符串。
删除:erase()
函数可以删除string中的一个子字符串:string& erase(size_t pos = 0, size_t len = npos);
。
- pos表示要删除的子字符串的起始下标;len表示要删除子字符串的长度。
提取:substr()
函数可以从string字符串中提取子字符串:string substr (size_t pos = 0, size_t len = npos) const;
。
- pos是要提取字符串的其实下标;len是要提取字符串的长度
查找:find()
函数可以在string字符串中查找子字符串出现的位置:Size_t find (const string& str, size_t pos = 0) const;
。
- 第一个参数是要查找的字符串;第二个参数是要开始查找的下标。
find
函数最终返回的是子字符串第一次出现在字符串中的其实下标,如果没有找到就会返回一个无穷大的数。
rfind
函数和find
函数的区别是,rfind
函数只找到第二个参数的位置。find_first_of()
函数用于查找子字符串和字符串共同具有的字符在字符串中首次出现的位置。
在c语言中,有两种表示字符串的方法:
- 用字符数组来容纳字符串,这样的字符串是可读可写的。
- 用字符串常量来表示,这样的字符串只能读,不能写。
在C++中,string在内部封装了与内存和容量相关的信息,所以string知道自己在内存中开始的位置、包含的字符串序列、字符序列的长度,当内存空间不足的时候,string还会自动调整内存大小。
引用
概念
char、int、float等类型称为基本类型,数据、结构体、类等称为聚合类型。
在C++中有一种比指针更加快捷的传递聚合类型数据的方式,那就是引用。引用可以看做是数据的一个别名,通过这个别名和原来的名字都能够找到这份数据。
引用的定义方式类似于指针,使用的符号是&:type &name = data;
。
- type就是被引用的数据类型。
- name是引用的名称。
- data是被引用的数据。
引用必须在定义的同时初始化,并且以后也要从一而终,不能再引用其他数据,这有点类似于常量(const变量)。
引用在定义时要添加&,但是在使用的时候不能添加&,使用的时候添加&表示取地址。由于引用和原始变量都指向同一个地址,所以引用也可以修改原始变量中所储存的数据。
如果不希望通过引用来修改原始的数据,那么就可以在定义的时候加上一个const,const type &name = value
也可以是 type const &name = value
, 这种引用方式为常引用。
在定义或者声明函数的时候,可以将函数的形参指定为引用的形式,这样在调用函数的时候就会将实参和形参绑定在一起,让他们都指代同一份数据,这样在函数体中修改了形参的数据,那么实参的数据也会被修改,从而达到在函数内部影响函数外部的数据。按引用传参在使用形式上比指针更加直观,鼓励使用引用代替指针。
在将引用作为函数返回值的时候应该注意的问题是,就是不能返回局部数据,因为局部数据在函数结束后就会销毁。
引用是对指针进行简单地封装,它的底层依然是通过指针来实现的,引用占用的内存和指针占用的内存长度是一样的,在32位环境下是4字节,在64位环境是8个字节,之所以不能获取引用的地址,是因为编译器进行了内部转换。编译器不然获取引用的地址,引用还是会占用内存的。
引用和指针的区别:
- 在定义引用的时候必须要初始化,并且以后也不能改变,即不能指向其他数据,指针没有这个限制。
- 指针可以有多级,但是引用只能有一级。
指针就是数据或者代码在内存中的地址,指针变量指向的就是内存中的数据或者代码。指针只能指向内存,不能指向寄存器或者硬盘,因为寄存器或者硬盘没法寻址。有些数据,比如表达式的结果,函数的返回值等可能储存在寄存器中,一旦被放在寄存器中就没法通过&访问它们的地址了,所以就不能使用指针指向它们。
指针的类型与他所要指向的数据的类型严格对应。
常量表达式
不包含变量的表达式称为常量表达式,常量表达式由于不含有变量,所以在编译阶段就能够求值,编译器不会分配单独的内存来储存常量表达式的值,而是将常量表达式的值和代码合并到一起,放到虚拟地址空间中的代码区。所以常量表达式从汇编的角度看就是一个立即数,会被硬编码到指令中,不能够寻址。
所以常量表达式虽然储存在内存中,但是不能够寻址,所以也不能够使用&来获取他的地址,更不能使用指针指向它。
编译器会为const 引用创建一个临时变量,使得引用更加灵活和通用。给引用添加const限定后,不但可以将引用绑定到临时数据,还可以将引用绑定到类型相近的数据,这使得引用更加灵活和通用,它们背后的机制都是临时变量。所以引用类型的函数形参尽量使用coonst,当引用作为函数形参时,如果函数内部不会修改引用的值得话,尽量使用const限制。
概括起来将引用类型的形参添加const限制类型的理由有三个:
- 使用const可以避免无意中修改数据的编程错误。
- const能让函数接收const和非const类型的实参,否则将只能接收非const类型的实参。
- 使用const引用能够让函数正确生成并使用临时变量。
继承与派生
概念
继承(inheritance)就是一个类从另一个类获取成员变量和成员函数的过程。
派生(derive)和继承一个概念,只是站的角度不同。
被继承的类称为父类或者基类,继承的类称为子类或者是派生类。派生类拥有基类的成员还可以定义自己的新成员,来增强类的功能。继承的成员可以通过子类对象访问,就像自己的一样。
继承的一般语法:class 派生类 : 继承方式 基类{派生类新增加的成员};
。继承的方式包括public
、private
、protected
,如果不写那么默认就是private。
protected成员和private函数类似,不能通过对象来访问。但是当存在继承关系时,基类中的protect成员可以在派生类中使用,但是基类中的private成员是不能被使用的。
不同继承方式在派生类中的访问权限
public继承
- 基类中的public在派生类中的成员也是public属性。
- 基类中的protected成员在派生类中的属性也是protected。
- 基类中的private成员不能在派生类中使用。
protected继承
- 基类中的public成员在派生类属性为protected 。
- 基类中的protected成员在派生类中的属性为protected。
- 基类中的所有private成员在派生类中不能使用。
private继承
- 基类中的所有public成员在派生类中为private属性。
- 基类中的所有protected成员在派生类中的属性为private。
- 基类中的所有private成员在派生类中不能使用。
基类成员在派生类中的访问权限不得高于继承方式中指定的权限。不管继承方式如何,基类中的private成员始终不能在派生类中使用。基类中的private成员不能被派生类使用,并不是说不能被继承。在开发中一般是用public继承方式。
在派生类中访问基类的private成员的唯一方式就是借助基类的非private成员函数。
使用using关键字可以修改基类中的public和protected成员的访问权限,不能改变private成员的访问权限。
//派生类Student
class Student : public People {
public:
void learning();
public:
using People::m_name; //将protected改为public
using People::m_age; //将protected改为public
float m_score;
private:
using People::show; //将public改为private
};
派生类中的成员和基类的成员重名,就会遮蔽从基类继承过来的成员。基类成员函数不管参数如何只要名字一样就会造成遮蔽,所以说基类成员函数和派生类成员函数不会构成重载。
派生类的作用域嵌套在基类的作用域中,如果一个名字在派生类的作用域中无法找到,编译器会继续到外层的基类作用域中查找该名字的定义。外层作用域中定义或者声明的名字,那么内层作用域都能访问这个名字。名字查找是从内层逐渐向外层查找的,如果内层找到了,就不会查找了,这个过程叫名字查找。
编译器只会把同一个作用域中的同名函数作为重载函数的选项。
继承时的内存模型
在继承时,派生类的内存可以看成是基类成员和新增成员变量的总和,所有成员函数任然储存在代码区,所有对象共享。在派生类的对象模型中,会包含所有的基类成员变量,这种方法可以直接访问基类成员变量,无需经过几层间接计算。
基类和派生类的构造函数
类的构造函数不能够继承,派生类继承的成员函数的初始化也是需要派生类的构造函数来完成,但是基类中的private变量是不能使用派生类的构造函数来初始化的,因为派生不能访问。
在派生类的初始化列表中,可以调用基类的构造函数来初始化变量,派生类总是先调用基类的构造函数,在执行其他代码。下面代码就是student的构造函数调用了people的构造函数来初始化。
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
需要注意的是构造函数不会被继承,所以只能将基类的构造函数放在函数头部,不能放在函数体中,不能当做普通函数来使用。这里在函数头部是对构造函数的调用,而不是声明,所以这里传递的是实参,所以这里传递的不仅可以是派生类构造函数参数列表中的参数,也可以是局部变量、常量等。
构造函数的调用顺序是自顶向下,按照继承的层次调用的,从基类到派生类。派生类只能调用直接基类的构造函数,不能调用间接基类的。
在通过派生类创建对象时,必须要调用基类的构造函数。
基类和派生类的析构函数
析构函数也不能够被继承,在派生类的析构函数中不用显式的调用基类的析构函数,因为每个类都有一个析构函数,编译器知道如何选择。析构函数的执行顺序和构造函数的执行顺序相反。
多重继承
一个派生类有多个基类就叫多重继承。多继承容易让代码逻辑变复杂,所以在Java、C#、PHP中取消了多重继承。
class D: public A, private B, protected C {
//类D新增加的成员
}
基类构造函数的调用顺序和他们在派生类构造函数中出现的顺序无关,而是和声明派生类时基类出现的顺序相同。
当多个基类中有同名的成员时,如果直接访问就会造成命名冲突,编译器不知道使用哪个基类的成员,这时候需要加上类名和域解析符::
。
多重继承时的对象内存模型
基类对象的排列顺序和继承时声明的顺序相同。
借助指针访问private成员
C++不允许通过对象来访问private成员,不管是通过对象变量还是通过对象指针。不过这种限制是语法层面的,通过指针其实还是可以访问到private成员变量的。
使用偏移
成员变量和对象的开头位置有一定的距离,只要知道了成员变量距离对象头部的偏移,就能通过指针访问到。所以知道对象的起始地址,然后加上变量的偏移,就能够知道变量的地址, 知道了成员变量的地址和类型,就能够知道它的值。
其实通过对象指针来访问成员变量的时候,编译器实际上也是通过这种方式来获取值得,如下面代码。
class A{
public:
A(int a, int b, int c);
public:
int m_a;
int m_b;
int m_c;
};
int b = p->m_b;
//上一句在编译器内部会转换为下面这样
int b = *(int*)( (int)p + sizeof(int) );
//其中p 是对象 obj 的指针,(int)p将指针转换为一个整数,这样才能进行加法运算;sizeof(int)用来计算 m_b 的偏移;(int)p + sizeof(int)得到的就是 m_b 的地址,不过因为此时是int类型,所以还需要强制转换为int *类型;开头的*用来获取地址上的数据
//如果使用对象指针访问变量m_a的话如下
int a = p -> m_a;
//在编译器中转换为
int a = * (int*) ( (int)p + 0 );
突破访问权限的限制就需要我们手动转换,而不是使用编译器自动转换。只要能够正确的计算出偏移即可。
下面代码就是指针的偏移来实现访问private变量
#include <iostream>
using namespace std;
class A{
public:
A(int a, int b, int c);
private:
int m_a;
int m_b;
int m_c;
};
A::A(int a, int b, int c): m_a(a), m_b(b), m_c(c){ }
int main(){
A obj(10, 20, 30);
int a1 = *(int*)&obj;
int b = *(int*)( (int)&obj + sizeof(int) );
A *p = new A(40, 50, 60);
int a2 = *(int*)p;
int c = *(int*)( (int)p + sizeof(int)*2 );
cout<<"a1="<<a1<<", a2="<<a2<<", b="<<b<<", c="<<c<<endl;
return 0;
}
运行结果是a1=10, a2=40, b=20, c=60
,可知C++不允许访问private变量只是在语法层面的,是指访问权限仅仅对取成员运算符.
和->
起作用。但是无法阻止直接通过指针来访问。
虚继承和虚基类
为了解决多继承时的命名冲突和冗余数据问题,提出了虚继承,使得派生类中只保留一份间接基类成员。在继承方式前面加上virtual
关键字就是虚继承。
class B: virtual public A{ //虚继承
//todo
};
虚继承的目的是让某个类作出声明,承诺愿意共享它的基类。其中被共享的基类称为虚基类。不论虚基类在继承体系中出现多少次,在派生类中都只包含一份虚基类的成员。
所以必须在虚派生的真实需求出现前就已经完成虚派生的操作。虚派生只影响从虚基类的派生类中进一步派生出来的类,不会影响派生类本身。
虚成员的可见性
虚继承的最终派生类中只保存了一份虚基类的成员,所以该成员可以被直接访问,不会产生二义性。如果虚基类的成员只被一条派生路径覆盖,那么任然可以访问这个被覆盖的成员。如果该成员被两条或者多条路径覆盖,那么久不能直接访问了,需要指明该成员是哪一个类的。
不提倡使用多继承。
虚继承时的构造函数
在虚继承中,虚基类是由最终的派生类初始化的,所以最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。在普通继承中,派生类构造函数中只能调用直接基类的构造函数,而不能调用间接基类的。
虚继承构造函数的执行顺序与普通继承不同,在最终派生类的构造函数调用列表中,不管各个构造函数出现的顺序,编译器总是先调用虚基类的构造函数,再按照顺序调用其他的构造函数。在普通继承中,构造函数的调用顺序是按照类声明时的基类的出现顺序调用的。
虚继承时候的内存模型
对于普通继承,基类对象始终位于派生类对象的对面,不管继承层次有多深,它相对于派生类对象顶部的偏移量是固定的。
对于虚继承,和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量后面,这样的话随着继承层级的增加,基类的成员变量的偏移就会改变,这样就不能使用固定的偏移量来访问基类的成员了。
虚继承将派生类分为固定部分和共享部分,并把共享部分放在最后。
将派生类赋值给基类(向上转型)
类也是一种数据结构,可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这个在C++中称为向上转型。当然将基类赋值给派生类的叫做向下转型。
向上转型非常安全,可以由编译器自动完成,向下转型有风险,需要手动干预。
赋值的本质是将现有的数据写入已经分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值的问题。
将派生类对象赋值给基类对象,会舍弃派生类的新增成员,这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,不能有基类对象给派生类对象赋值。因为基类对象中不包含派生类对象中的新增成员。
将派生类指针赋值给基类指针
可以将派生类指针赋值给基类指针。与对象变量的赋值不同,对象指针的赋值不需要拷贝对象的成员变量,也不会修改对象的数据,仅仅改变了指针的指向。
通过基类指针访问派生类成员
派生类指针赋值给基类指针时,通过基类指针只能使用派生类的成员变量,不能使用派生类的成员函数,使用的成员函数还是基类的。
这是因为将派生类的指针赋值给基类指针后,基类指针就指向了派生类的对象,这样的话就使得this指针发生变化,也指向了派生类的对象,所以访问的成员变量是派生类的。但是编译器通过基类指针来访问成员变量,但是却不是通过指针来访问成员函数的。编译器是通过指针的类型来访问成员函数。所以即使将派生类的指针赋值给了基类指针,但是基类指针的类型是没有变化的。
所以总结来说就是:编译器通过指针来访问成员变量,指针指向那个对象就使用那个对象的数据;编译器通过指针的类型来访问成员函数,指针属于哪个类的类型就使用哪个类的成员变量。
将派生类引用赋值给基类引用
引用在本质上就使用指针来实现的,所以指针和引用的效果是一样的。
将派生类指针赋值给基类指针过程
将派生类指针赋值给基类指针后,会发现他们的值有可能相等有可能不相等。
编译器在赋值之前可能会对现有的值进行一些处理,比如将double类型的数据赋值给int类型的变量,编译器会抹掉小数部分。将派生类的指针赋值给基类指针的时候也是同样的道理,编译器可能会在赋值前对值进行一些处理。
多态与虚函数
概念
因为基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。C++增加了虚函数,来让基类指针访问派生类的成员函数,虚函数只需要在函数声明前面加上virtual
关键字。
使用虚函数,基类指针指向基类对象的时候就使用的基类的成员,即能够使用成员变量也能够使用成员函数。指向派生类的时候就使用的是派生类的成员。所以基类指针即能够按照基类的方式来做事,也能够按照派生类的方式来做事,有多种形态,或者说是表现方式,这种现象称为多态。
虚函数唯一的作用就是构成多态。
提供多态的目的是:通过基类指针可以对派生类(直接派生和间接派生)的成员变量和成员函数进行全方位的访问。如果没有多态就只能够访问派生类的成员变量。虚函数是根据指针的指向来调用的,指针指向哪个类的对象,就调用哪个类的函数。
引用本质上是指针,所以引用也能够实现多态,但是因为引用在初始化之后就不能改变,所以在多态方面缺乏表现力。
对于具有复杂继承关系的大中型程序,多态可以增加其灵活性,让代码更加有表现力。如果一个程序中派生类比较多,如果不使用多态就需要定义多个指针,但是如果使用多态,就只需要一个指针变量就能够调用所有派生类的虚函数。
虚函数
虚函数只需要在函数声明的时候加上virtual
关键字,在函数定义的时候可以加可以不加。可以只将基类中的函数声明为虚函数,这样所有的派生类中的具有遮蔽关系的同名函数都将自动成为为虚函数。
当基类中定义了虚函数时,如果没有派生类定义新的虚函数来遮蔽此函数,那么将使用基类的虚函数。只有在派生类的虚函数覆盖基类的虚函数才能构成多态,即通过基类指针访问派生类函数。
构造函数不能是虚函数。
析构函数可以声明为虚函数,而且有时候必须声明为虚函数。
封装、继承、多态 — 面向对象的三大特征。
构成多态的条件
- 必须存在继承关系。
- 继承关系中必须有同名的虚函数,并且他们之间有覆盖关系。
- 存在基类的指针,通过指针调用虚函数。
- 基类指针只能访问从基类继承过去的成员,不能访问派生类新增的成员。
- 如果类的成员函数在继承后希望更改其功能的,一般将它声明为虚函数。
虚析构函数
构造函数不能是虚函数因为:
- 派生类不能继承基类的构造函数,将构造函数声明为虚函数没有意义。
- C++中的构造函数用于在创建对象时进行初始化工作,在执行构造函数之前对象尚未创建完成,虚函数表不存在。
析构函数默认情况下一般声明为虚函数:
析构函数在默写情况下,如果不定义为虚函数,容易造成内存泄漏。因为当delete对象指针的时候,如果是基类指针指向的派生类,那么在delete派生类的时候,会调用析构函数。因为通过指针调用成员函数的时候,编译器会根据指针的类型而不是指针指向的对象来调用成员函数。所以这样会导致调用到基类的析构函数而非派生类的析构函数。如果在派生类的构造函数中申请了内存,那么将无法释放,造成内存泄漏。
派生类的析构函数始终会调用基类的析构函数。将基类的析构函数声明为虚函数,派生类的析构函数也自动成为虚函数。这个时候编译器就不会根据指针的类型,而是根据指针指向的对象来选择成员函数。
大部分情况下都是将基类的析构函数声明为虚函数。
纯虚构函数和抽象类
可以将虚函数声明为纯虚函数:
virtual 返回值类型 函数名 (函数参数) = 0;
纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0
,表明是纯虚函数。
最后的=0并不表示函数返回值为0,它只起形式上的作用,告诉编译系统“这是纯虚函数”。
包含纯虚函数的类称为抽象类,之所以叫抽象类是因为无法实例化,也就无法创建对象。纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。
抽象类通常作为基类,让派生类去实现纯虚函数,派生类必须实现纯虚函数才能被实例化。定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现。这部分功能往往是基类所不需要的,或者在基类中无法实现的。
抽象类声明的纯虚函数虽然没有实现,但是却强制要求派生类实现,否则就无法完成实例化。
抽象类除了约束派生类的功能,还可以实现多态。因为如果基类中没有声明,那么使用基类指针调用派生类中的成员函数的时候就会出现错误。
只有虚函数才能被声明为纯虚函数。
多态的实现机制
如果函数是虚函数,并且有派生类的同名函数遮蔽它,那么编译器会根据指针的指向找到该函数,也就是说,指针指向的对象属于哪个类就调用那个类的函数,这就是多态。
编译器之所以能够通过指针指向的对象找到虚函数,是因为在创建对象的时候额外的增加了虚函数表。
如果一个类包含了虚函数,那么在创建该类的对象的时候会额外的增加一个数组,数组的每个元素都是虚函数的入口地址。不过数组和对象时分开储存的,为了将对象和数组关联起来,编译器还需要在对象中插入一个指针,指向数组的起始地址。这个数组就是虚函数表,简写为vtable。
在对象的开头有一个指针,指向虚函数表,并且这个指针始终位于对象的开头位置。
在虚函数表中,基类的虚函数在vtable中的下标是固定的,不会随着继承层次的增加而改变,派生类新增的虚函数放在vtable的最后,如果派生类中有同名的虚函数遮蔽了基类的虚函数,那么将使用派生类的虚函数替换基类的虚函数,这样的具有遮蔽关系的虚函数在vtable中只会出现一次。
当通过指针调用虚函数时,先根据指针vfptr,在根据vfptr找到虚函数的入口地址。
p -> display()
( *( *(p+0) + 0 ) )(p);//上面的调用在编译器中会转换成下面这样
可以发现,转换后的表达式是固定的,只要调用了虚函数,不管值哪个类中,都会使用这个表达式。换句话说,编译器不管基类指针指向哪里。一律转换为相同的表达式。
转换后的表达式没有用到与p的类型有关的信息,只要知道指针就可以调用函数,这个和名字编码算法有着本质的区别。
typeid运算符
typeid用来获取一个表达式的类型信息。基本类型的数据,类型信息比较简单,主要是指数据的类型。对于类类型的数据,也就是对象,类型信息是指对象所属的类、所包含的成员、所在的继承关系等。
类型信息是创建数据的模板,数据占用多大内存,能进行什么操作等都是有类型信息决定的。typeid的操作对象既可以是表达式,也可以是数据类型。
typeid( dataType )
typeid( expression )
typeid必须要带上括号,typeid会把获取到的类型信息保存到一个type_info类型的对象中,并返回该对象的常引用。返回的type_info类的几个成员函数:
name()
用来返回类型的名称。raw_name()
用来返回名字编码算法产生的新名字。hash_code()
用来返回当前类型对应的hash值。
C++标准规定,type_info类中至少要有下面这四个public函数:
const char* name() const;
:返回一个能表示类型名称的字符串。bool before (const type_info& rhs) const;
:判断一个类型是否位于另一个类型的前面,rhs参数是一个type_info对象的引用。bool operator== (const type_info& rhs) const;
:重载运算符==
,判断两个类型是否相同,rhs参数是一个type_info对象的引用。bool operator!= (const type_info& rhs) const;
:重载运算符!=
,判断两个类型是否不同,rhs参数是一个type_info对象的引用。
typeid运算符经常用来判断两个类型是否相等。一个类型不管使用多少次,编译器都只为它创造一个对象,所有typeid都返回这个对象的引用。
但是为了减小编译后的文件大小,编译器不会为所有类型创建type_info对象,只会为使用typeid运算符的类型创建。但是带虚函数的类,不管有没有使用typeid运算符,编译器都会为带虚函数的类创建type_info对象。
类型识别机制
一般情况下,在编译期间就能确定一个表达式的类型,但是当存在多态时,有些表达式的类型在编译期间就无法确定,必须要等到程序运行后根据实际的环境来确定。
对象内存模型:
- 如果没有虚函数也没有虚继承,那么对象内存模型中只有成员变量
- 如果类包含了虚函数,那么就会额外增加一个虚函数表,并在对象内存中插入一个指针,指向这个虚函数表。同时如果该类的对象内存中还会额外增加类型信息,即type_info对象。
- 如果类包含了虚继承,那么就会额外添加一个虚基类表,并在对象内存中插入一个指针,指向这个虚基类表。
如果类中包含了虚函数,那么编译器不仅会创建一个虚函数表在对象的开头,并且还会在虚函数表的开头插入一个指针,指向当前内对应的type_info对象。当前程序在运行阶段获取类型信息时,可以通过对象指针找到虚函数表指针,再通过虚函数表指针找到type_info对象的指针,进而取得类型信息。
编译器在编译阶段无法确定基类指针指向那个对象,所以就无法获取指针指向对象的类型信息,但是编译器在编译阶段可以做好各种准备,这样程序在运行后可以借助这些准备好的数据来获取类型信息,这些信息包括:
- 创建type_info对象,并在虚函数表的开头插入一个指针,指向type_info对象。
- 将获取类型信息的操作转换为类似指针的语句。
**(p->vfptr - 1)
。
这种在程序运行后确定对象的类型信息的机制称为运行时类型识别(RTTI)。在C++中,只有包含了虚函数时才会用到RTTI机制,其他的所有情况都可以在编译阶段确定类型信息。
多态是面向对象编程的一个重要特征,极大地增加了程序的灵活性,但是支持多态的代价也很大,不能提前在编译阶段确定有的信息,会消耗更多的内存和CPU资源。
cpu访问内存需要的是地址,将变量名、函数名和地址对应起来的操作叫做符号绑定。在一般情况下,在编译期间就能够找到函数名对应的地址,完成函数的绑定,程序运行后直接使用即可,这个叫做静态绑定。有的时候在编译期间不能确定调用的是哪个函数,必须要等到程序运行后根据具体的环境或者操作来决定,这个叫做动态绑定。
静态语言:在定义的时候就显式的指明了类型,并且指明类型后就不能够更改了,所以编译器在编译期间就能够知道大部分的表达式的类型,这种语言称为静态语言。
动态语言:在定义变量的时候不需要指明类型,并且变量的类型可以随时改变,编译器在编译期间也不能确定表达式的类型信息,只能等到程序运行后再董涛获取,称为动态语言。
动态语言为了灵活,部署简单,一般是一遍编译一边执行,模糊了传统的编译和运行的过程。
运算符重载
概念
运算符重载就是让同一个运算符拥有不同的功能。在实际运用中,+
对不同类型的数据进行加法操作,<<
既可以作为位移运算符可以作为cout的符号进行输出,这些都是运算符重载。
运算符重载就是定义了一个函数,在函数体内部实现想要的功能,当需要用到运算符的时候,编译器会自动调用这个函数。所以运算符重载是通过函数实现的,本质上还是函数重载。运算符重载的格式为:
返回值类型 operator 运算符名称(形参列表){
//TO DO
}
operator是关键字,专门用于定义重载运算符的函数。
运算符重载函数除了函数名有特定的格式,其他地方和普通函数没有区别。运算符重载函数不止可以作为类的成员函数,还可以作为全局函数。
- 并不是所有的运算符都能重载,长度运算符
sizeof
,条件运算符:?
,成员运算符.
,域解析运算符::
不能被重载。 - 重载不能改变运算符的优先级和结合性。
- 重载不会改变运算符的用法,原来有几个操作数、操作数在左边还是右边,这些都不会改变。
- 运算符重载函数不能有默认的参数,否则就改变了运算符操作数的个数。
- 运算符重载函数既可以作为类的成员函数,也可以作为全局函数。
- 箭头运算符
->
,下标运算符[]
,函数调用运算符()
,赋值运算符=
只能以成员函数的形式重载。
将运算符重载函数作为类的成员函数时,二元运算符的参数只有一个,一元运算符不需要参数。因为作为类的成员函数的时候,有一个参数是隐含的。比如当重载了+
后,c3 = c1 + c2
在编译的时候会转换为c3 = c1.operator+(c2)
,通过this指针隐式的访问c1的成员变量。
将运算符重载函数作为全局函数时,二元操作符就需要两个参数,一元操作符需要一个参数,其中必须要有一个参数是对象,让编译器知道这是程序员自己定义的运算符,防止修改用于内置类型的运算符。
将运算符重载函数作为全局函数时,一般都需要在类中将该函数声明为友元函数。
重载<<
和>>
在标准库中已经对左移和右移运算符进行了重载,使其能够用于不同的数据输入输出。但是输入输出的对象只能是C++内置的数据类型和标准库中包含的数据类型。如果自己定义了一种数据类型,要使用<<
和>>
就需要自己重载。
cout是ostream类的对象,cin是istream类的对象,要重载<<
和>>
就必须以全局函数的形式重载<<
和>>
,否则的话就要修改标准库中的类。
重载输入运算符>>
:
istream & operator>>(istream &in, complex &A){
in >> A.m_real >> A.m_imag;
return in;
}
其中istream表示输入流,cin是istream类的对象,只不过这个对象是在标准库中定义的。返回引用是为了能够连续的读取复数,如果不返回引用的话就只能一个一个的读取。并且运算符重载函数用到了complex中的private变量,必须在complex内中声明为友元函数。
重载输出运算符<<
:
ostream & operator<<(ostream &out, complex &A){
out << A.m_real << " + " << A.m_imag << "i";
return out;
}
ostream表示输出流,cout是ostream类的对象。
重载[]`
c++规定下标运算符必须以成员函数的形式重载。
1.返回值类型 & operator[](参数);
或者是:
2.const 返回值类型 & operator[](参数) const;
使用第一种声明方式,[]
不仅可以访问元素,还可以修改元素。
使用第二种方式,[]
只能访问元素并不能修改元素。在实际应用中,我们应该提供两种形式,适应const对象,因为通过const对象只能调用const成员函数,如果不提供第二种形式将无法访问const对象的任何元素。
重载++
和--
自增++
和自减–
运算符都是一元运算符,他的前置形式和后置形式都可以被重载。
1.返回值类型 operator++(); //++i,前置形式
2.返回值类型 operator++(int i); //i++,后置形式
operator++();
函数实现自增的前置形式,自己返回运行结果。
operator++(int i);
函数实现自增的后置形式,返回值是对象本身,但是之后再次使用该对象的时候,对象自增了,所以在该函数的函数体中,先将对象保存,然后在调用一次run()函数,之后再将先保存的对象返回。在这个函数中参数i是没有任何意义的,只是区分是前置还是后置形式。
重载new
和delete
内存运算符new
、new[]
、delete[]
、delete
也可以进行重载,重载形式既可以是成员函数也可以是全局函数。
以成员函数重载new:
void* classname::operator new(size_t size){
//todo;
}
以全局函数重载new:
void* operator new(size_t size){
//todo;
}
在重载new和new[]的时候,无论是作为成员函数还是全局函数,第一个参数必须是size_t类型。size_t表示的是要分配空间的大小。对于new[]的重载函数而言,size_t表示的是所需要分配的所有空间总和。
size_t 在头文件<cstdio>中被定义为 typedef unsigned int size_t;就是无符号整形
当然重载函数可以有其他参数,但是都必须要有默认值,并且第一个参数的类型必须是size_t。
以成员函数重载delete:
void* classname::operator delete(void *ptr){
//todo;
}
以全局函数重载delete:
void* operator delete(void *ptr){
//todo;
}
重载强制类型转换运算符()
在c++中,类型的名字(包括类的名字)本身也是一种运算符,即类型强制转换运算符。
类型强制转换运算符是单目运算符,也可以被重载,但是只能重载为成员函数,不能重载为全局函数。经过重载后(类型名)对象
这个对对象进行强制类型转换的表达式就等价于对象.operator 类型名()
,即变成对运算符函数的调用。
重载强制类型转换运算符时,不需要指定返回值类型,因为返回值类型是确定的,就是运算符本身代表的类型。
注意事项
- 重载后运算符的含义应该符合原有用法习惯。
- 运算符重载不改变运算符的优先级。
.
,.*
,::
,?:
,sizeof
等运算符不能够被重载。- 重载运算符
()
,[]
,->
,=
的时候,只能够重载为成员函数,不能重载为全局函数。 - 运算符重载实际是将运算符重载为一个函数,使用运算符的表达式就是被解释为对重载函数的调用。
- 运算符可以重载为全局函数,此时的函数的参数个数就是运算符的操作数个数,运算符的操作数就成为函数的实参。
- 运算符可以重载为成员函数,此时的函数参数个数就是运算符的操作数减一,运算符的操作数有一个成为作用的对象,其余的成为函数的实参。
- 类型的名字可以作为强制类型转换运算符,也可以重载为类的成员函数,使得对象自动转换为某种类型。
- 自增、自减运算符各有两种重载方式,用于区别前置用法和后置用法。
模板
概念
在c++中,数据的类型也可以通过参数来传递,在函数定义的时候可以不指名具体的数据类型,当发生函数调用的时候,编译器可以根据传入的实参自动推断数据类型,这就是类型的参数化。值和类型是数据的两个主要特征,在C++中都可以被参数化。
函数模板就是建立一个通用函数,需要用到的数据类型(包括返回值类型、形参类型、局部变量类型)可以不具体指定,而是使用一个虚拟的类型来代替,等发生函数调用的时候根据传入的实参来逆推出真正的类型。
一旦定义了函数模板,就可以将类型参数用于函数定义和函数声明了。原来使用int
、float
、char
等内置类型的地方,都可以用函数参数来代替。
template是定义函数模板的关键字,后面紧跟尖括号<>
,尖括号包围的是类型参数,typename是另一个关键字,用来声明具体的类型参数,template<typename T>
称为模板头。
template <typename 类型参数1, typename 类型参数2, ...> 返回值类型 函数名(形参列表){
//在函数体中可以使用类型参数
}
类型参数可以有很多个,但是需要用逗号区分,类型参数 使用<>
来包围,形式参数使用()
包围。typename关键字可以使用class关键字来代替,因为早期c++并没有引入新的关键字,而是使用的class关键字来指明类型参数。
函数模板可以提前声明,但是声明是需要带上模板头,并且模板头和函数定义(声明)是一个不可分割的整体,可以换行,但是不能添加分号。
除了函数模板外,还支持类模板。函数模板中定义的类型参数可以在函数声明和函数定义中使用,类模板中定义的类型参数可以用在类声明和类实现中,类模板的目的同样是将数据的类型参数化。
template <typename 类型参数1, typename 类型参数2,...> class 类名{
//todo
};
类模板和函数模板一样,类型参数不能为空,多个类型参数使用逗号区隔。
一旦声明了类模板,就可以将类型参数用于类的成员函数和成员变量。除了在类的声明中加上模板头,还需要在类外定义成员函数时加上模板头。
template <typename 类型参数1, typename 类型参数2, ...>
返回值类型 类名<类型参数1, 类型参数2, ...>::函数名(形参列表){
//todo
}
- 注:除了template关键字后面需要指定类型参数,类名point后面也要带上类型参数,只是不加typename关键字。
和函数模板不同,类模板在实例化的时候必须要指明数据类型,编译器不能根据给定的数据推断出数据类型。使用对象指针的方式进行实例化的时候也需要在两边都指明具体的数据类型,且保持一致。
模板支持的类型是宽泛的,没有限制的,可以使用任意类型来替换,这种编程称为泛式编程。可以将参数T看做一个泛型,将int、char等看做是一个具体的类型。
c++允许对函数模板进行重载。
- 注:编程语言根据语言在定义变量的时候是否需要显示地指明数据类型可以分为强类型语言和弱类型语言。强类型语言在定义变量的时候需要指明数据类型,并且一旦为变量指明某种了某种数据类型,该变量以后就不能赋予其他类型的数据,除非经过强制类型转换和隐式转换。
int a = 100; //不转换
a = 12.34; //隐式转换(直接舍去小数部分,得到12)
a = (int)"http://c.biancheng.net"; //强制转换(得到字符串的地址)
弱类型语言在定义变量的时候不需要显式的指明数据类型,编译器会根据赋给变量的数据自动推导出类型,并且可以赋予变量不同类型的数据,并且可以赋给变量不同类型的数据。
var a = 100; //赋给整数
a = 12.34; //赋给小数
a = "http://c.biancheng.net"; //赋给字符串
a = new Array("JavaScript","React","JSON"); //赋给数组
不管是强类型还是弱类型语言,在编译器内部都有一个类型系统来维护变量的各种信息。
对于强类型语言编译器在编译期间就能够检测到变量的操作是否正确,这样就不用在程序执行的时候再维护一套类型信息,减少了内存的使用,加快了程序的运行。但是强类型语言也存在一些不能在编译期间确定的类型,比如c++中的多态,编译器在编译阶段会在对象内存模型中增加虚函数表,type_info等信息辅助信息,来维护一个完整的继承链,等到程序运行的时候在确定调用那个函数。对于弱类型语言来说编译的意义不大,因为即使编译了也有很多东西不能确定。
弱类型语言一般是一遍编译一遍执行,这样可以根据上下文推导出很多有用的信息,将这种一遍执行一遍编译的语言称为解释型语言,而将传统的先编译后执行的称为编译型语言。
强类型语言较为严谨,在编译时就能发现很多错误,适合开发大型的、系统级的、工业级的项目;而弱类型语言较为灵活,编码效率高,部署容易,学习成本低,在 Web 开发中大显身手。另外,强类型语言的 IDE 一般都比较强大,代码感知能力好,提示信息丰富;而弱类型语言一般都是在编辑器中直接书写代码。
实参推断
在使用类模板创建对象的时候,需要显式的指明实参。例如下面创建对象的时候,需要指明实参的类型。这样就在编译的时候,编译器就不用自己推断了,可以直接拿来使用。
template<typename T1, typename T2> class Point;
Point<int, int> p1(10, 20); //在栈上创建对象
Point<char*, char*> *p = new Point<char*, char*>("东京180度", "北纬210度"); //在堆上创建对象
对于函数模板,调用函数时可以不显式的指明实参。
//函数声明
template<typename T> void Swap(T &a, T &b);
//函数调用
int n1 = 100, n2 = 200;
Swap(n1, n2);
float f1 = 12.5, f2 = 56.93;
Swap(f1, f2);
编译器会根据实参的类型自动推断T的类型,这种通过函数实参来说确定模板实参的过程称为模板实参推断。
函数模板的实参推断是指在函数调用过程中根据实参的类型来寻找类型参数的具体类型的过程,这在大部分情况下是奏效的,但是当类型参数较多的时候,就会有个别的类型无法推断出来,这个时候就必须要显式的指明实参。也就是说,如果编译器不能根据实参来推断出模板中的所有类型,就会出现调用出错。
为函数模板和类模板指明实参的方式是一样的,都是在函数名后面添加<>
,里面包括具体的类型。显式指明的模板实参会按照从左到右的顺序与对应的模板参数匹配。
func<int, int>(10);
非类型参数
模板是一种泛型技术,目的是将数据的类型参数化,增强语言的灵活性,模板中除了支持类型参数还支持非类型参数。非类型参数,用来传递参数,不是类型,和普通的形参一样需要指明具体的类型,当调用一个函数模板或者通过一个类模板创建对象的时候,非类型参数会被用户提供或者是编译器推断出来。
非类型参数的类型不能随意指定,只能是一个整数或者指向对象或函数的指针。当非类型参数是一个整数的时候,传递给他的实参,或者由编译器推导出的实参必须是一个常量表达式。
当非类型参数是一个指针时,绑定到该指针的实参必须具有静态的生存期,所以实参必须储存在虚拟地址空间的静态数据区。局部变量位于栈区,动态创建的对象位于堆区。
实例化
模板不会占用内存,最终生成的函数或者类才会占用内存,由模板生成函数或者类的过程叫做模板的实例化,针对某个类型生成的特定版本的函数或者类叫做模板的一个实例。
模板可以看做是编译器的一组指令,它命令编译器生成我们想要的代码。模板的实例化是按需进行的,用到哪个类型就生成针对哪个类型的函数或者类,不会提前生成过多的代码。编译器根据传递给类型参数的实参来生成一个特定版本的函数或者类,并且相同类型只生成一次。
类模板的实例化,通过类模板创建对象的时候并不会实例化所有成员函数,只有等到真正调用的时候才会被实例化,如果一个成员函数永远不调用,那么就永远不会实例化,所以实例化是延迟的局部的。
模板引用多文件编程
不管是类还是函数,声明和定义的分离都是一样的,都是将函数定义放到其他文件中,最终要解决的问题也只有一个,就是函数调用和函数定义对应起来(找到函数定义的地址,并填充到函数调用处),这项工作的完成者就是连接器。
但是在模板中,将模板的声明和定义分散到不同的问题中是不对的。模板并不是真正的函数或者类,仅仅是用来生成函数或者类的一张图纸。
- 模板的实例化是按需进行的,用到哪个类型就生成针对哪个类型的函数或者类,不会提前生成代码。
- 模板的实例化是由编译器完成的,而不是由连接器。
- 实例化的过程中,需要知道模板的细节,包括声明和定义。
所以模板只是一个模板,是不占内存的,在编译的时候编译器根据需要生成对应类型的代码,所以模板的实例化是由编译器完成的,如果分开在两个文件中的话,是由链接器完成函数填充的工作,可能导致在链接期间找不到对应的实例。所以一般将模板的定义和声明都放在头文件中。
使用显式实例化可以将定义和声明放在两个文件中,显式实例化的方式如下。template后面不加<>
,直接跟函数原型,就是将模板实例化成和函数原型对应的一个具体版本。
template void Swap(double &a, double &a);
显式实例化类模板的方式一样,需要加上class。显式实例化一个类模板时,会一次性实例化该类的所有成员,包括成员函数和成员变量。
类模板与继承
- 类模板从类模板中派生
- 类模板从模板类派生
- 类模板从普通类派生
- 普通类从模板类派生
类模板与友元
- 函数、类、类的成员函数作为类模板的友元
- 函数模板作为类模板的友元
- 函数模板作为类的友元
- 类模板作为类模板的友元
异常处理
程序错误主要分为三种:语法错误、逻辑错误、运行时错误。异常机制是让能够捕捉到运行时出现的错误,告知用户发生了什么再终止程序。
捕获异常的语法为:
try{
//可能抛出异常的语句
}catch(exceptionType variable){
//处理异常的语句
}
try和catch是关键字,后面必须接着语句块。catch关键字后面的exceptionType variable表明了catch可以处理异常的类型。variable变量接收异常信息,当程序抛出异常的时候,会创建一份数据,这份数据包含了错误信息,程序员可以根据这些信息判断异常。
异常一旦检测到就会马上抛出,立刻被try检测到,并且不会在执行异常后面的语句。也就是检测到异常发生的时候就会跳转到catch的位置,位于异常点之后的语句就不会再执行。throw关键字是用来抛出一个异常,这个异常会被try检测到,进而被catch捕获。
异常类型可以使基本类型,也可以是聚合类型,c++本身或者标准库抛出的异常都是exception类的异常。所以抛出异常的时候会创建一个exception类或者其子类的对象。
异常是在运行阶段产生的,可以是任何类型,没法提前预测,所以不能在编译阶段判断类型是否正确,只有程序运行后抛出异常后才能将抛出的异常类型和catch处理的类型匹配。一个try可以跟着多个catch。一旦找到匹配的catch类型的话,其他的catch语句就不会被执行。
异常需要显式的抛出,才能被检测到,可以使用throw来抛出异常。
throw exceptionData;
exception是异常数据,可以包含任何的信息。throw关键字除了可以在函数体中抛出异常外,还可以在函数头和函数体之间,指明当前函数能够抛出的异常类型,称为异常规范。
double func (char param) throw (int);
表明函数func的返回值类型为double,有一个char类型的参数,只能抛出int类型的异常。如果函数需要抛出多种异常,可以使用逗号区分。
double func (char param) throw (int, char, exception);
exception类称为标准异常,exception类位于头文件中,声明为:
class exception{
public:
exception () throw(); //构造函数
exception (const exception&) throw(); //拷贝构造函数
exception& operator= (const exception&) throw(); //运算符重载
virtual ~exception() throw(); //虚析构函数
virtual const char* what() const throw(); //虚函数
}
面向对象
拷贝构造函数
对象的创建包括两部分,首先是分配空间,然后在初始化。
当以拷贝的方式进行初始化一个对象的时候,会调用用一个特殊的构造函数,就是拷贝构造函数。拷贝构造函数只有一个参数,类型一般是当前类的引用,而且一般是const引用。
一个类可以同时存在两个拷贝构造函数,一个函数的参数为const引用,另一个函数的参数为非const引用。
如果没有显式的定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数,这个默认的拷贝构造函数,就是使用老对象的成员变量对新对象的成员变量进行赋值。但是当类持有其他的资源的时候,比如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据。
当以拷贝方式初始化对象时会调用拷贝构造函数。初始化对象是指为对象分配内存后第一次向内存中填充数据,这个过程会调用构造函数,对象在创建后必须立即初始化。
初始化和赋值都是将数据写入内存中,在定义的同时赋值的叫做初始化,定义完后在赋值的叫做赋值,初始化只有一次,赋值可以有很多次。对于基本类型来说,初始化和赋值没有什么差别,但是对于类来说就有区别了,因为对象在初始化的时候会调用构造函数(以拷贝方式初始化会调用拷贝构造函数),赋值时会调用重载过的赋值运算符。
初始化对象时,会调用构造函数,不同的初始化方式会调用不同构造函数。如果传递的实参是来初始化,就会调用普通的构造函数;如果是用其他的对象的数据来初始化对象,就会调用拷贝构造函数,以拷贝的方式完成初始化。
浅拷贝和深拷贝
基本类型和简单地对象的数据之间的拷贝是按位复制内存,这种方式叫做浅拷贝,和调用memcpy函数的效果相似。
当类持有动态分配的内存、指向其他数据的指针等资源的时候,默认的拷贝函数就不能拷贝这些资源,需要显式的定义一个拷贝构造函数。显式的定义一个拷贝构造函数,除了可以将原有对象的成员变量拷贝给新对象,还会为新对象分配一块内存,并将原有对象所持有的内存拷贝过来。这样原来的对象和新的对象就没有关联,是相互独立的,更改一个对象的数据并不会影响另外一个对象。这种将对象持有的资源进行拷贝的行为叫做深拷贝,必须要显式的定义拷贝构造函数。
不使用显式的拷贝构造函数,使用默认的拷贝构造函数初始化带指针等资源的对象的时候,会导致拷贝后的对象的指针和原来的指向的是同样的一块内存。所以带有指针类型的成员变量的时候需要使用深拷贝,让原来的对象和新的对象相互独立。
当以拷贝的方式初始化一个对象的时候,会调用拷贝构造函数,当给一个对象赋值的时候,会调用重载过的赋值运算符。即使没有显式的重载赋值运算符,编辑器也会以默认的方式的重载,默认的方式重载运算符就是将原有的对象的所有成员变量赋值给新的对象,这和默认的拷贝构造函数的功能类似。
转换构造函数和类型转换函数
不同的数据类型之间可以相互转换,无需用户指明如何转换的称为自动类型转换,需要用户显式指明的称为强制类型转换。强制类型转换只适用于类,因为类型转换规则只能以类的成员函数的形式出现。
转换构造函数是将其他类型转换为当前类类型的一种构造函数,转换构造函数只有一个参数。转换构造函数也是一种构造函数,可以用来将其他类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。
当以拷贝方式初始化对象时,编译器先调用转换构造函数,然后再将数据拷贝给变量。构造函数是在创建对象的时候初始化对象,编译器会根据传递不同实参来匹配不同的构造函数。
- 默认构造函数:编译器自动生成的构造函数。
- 普通构造函数:用户自定义的构造函数。
- 拷贝构造函数:以拷贝方式初始化对象的时候调用。
- 转换构造函数:将其他类型转换为当前类类型的时候调用。
不管哪一种构造函数,都是用来初始化对象的,除了在创建对象的时候会初始化对象,在其他情况下也会调用构造函数,比如以拷贝的方式初始化对象时会调用拷贝构造函数,将其他类型转换为当前类类型时调用转换构造函数。
转换构造函数能够将其他类型转换为当前类型,但是不能反过来将当前类型转换为其他类型。类型转换函数可以将当前类类型转换为其他类型,类型转换函数只能以成员函数的方式出现在类中。
operator type(){
//todo
return data;
}
operator是C++关键字,type是要转换的目标类型,data是要返回的type类型数据。
因为知道了要返回type类型的数据,所以没必要给出返回值类型。类型转换函数看起来没有返回值类型,其实是隐式的指明了返回值类型。类型转换函数也没有参数,因为将当前类的对象转换为其他类型,编译器会把当前对象的地址赋给this指针,这样在函数体内就可以操作当前对象了。
类型转换函数和运算符重载相似,都使用operator关键字,因此也把类型转换函数称为类型转换运算符。
类型转换函数和转换构造函数的作用是相反的:转换构造函数会将其他类型转换为当前类类型,类型转换函数会将当前类类型转换其他类型。如果没有这两个函数那么将会编写大量的运算符重载函数来实现类型转换和运算。
类型转换
不同的数据类型之间可以相互转化,无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式的指明如何转换的称为强制类型转换。
隐式类型转换使用编译器内部的转换规则或者用户定义的转换构造函数以及类型转换函数。
数据是以二进制储存在内存中的,各种的数据类型其实是指数据的解释方式,在使用前必须确定这种数据的解释方式。这个解释方式就是由数据类型来确定的。数据类型用来说明数据的类型,确定数据的解释方式。数据类型包括内置类型和自定义类型。
数据类型转换就是对数据所占用的二进制位重新解释,如果有必要,在重新解释的同时还会修改数据。隐式类型转换,编译器可以根据已知的转换规则来决定是否需要修改数据的二进制位;而对于强制类型转换,由于没有对应的转换规则,所以能做的事情仅仅是重新解释数据的二进制位,但无法对数据的二进制位进行修正,这就是隐式类型转换和强制类型转换最根本区别。
修改数据的二进制位非常重要,能够将转换后的数据调整到正确的值。
隐式类型转换必须使用已知的转换规则,虽然灵活性受到了限制,但是由于能够对数据进行恰当地调整,所以更加安全(几乎没有风险)。强制类型转换能够在更大范围的数据类型之间进行转换,例如不同类型指针(引用)之间的转换、从 const 到非 const 的转换、从 int 到指针的转换(有些编译器也允许反过来)等,这虽然增加了灵活性,但是由于不能恰当地调整数据,所以也充满了风险,程序员要小心使用。
强制转换不是万能的,类型转换只能发生在相关类型或者相近类型之间,两个毫不相干的类型不能相互转换,即使使用强制类型转换也不行。两个没有继承关系的类不能相互转换,基类不能向派生类转换(向下转型),类类型不能向基本类型转换,指针和类类型之间不能相互转换。
输入输出流
c++中包含了很多io类,这些类统称为流类。
- istream:常用于接收从键盘输入的数据;
- ostream:常用于将数据输出到屏幕上;
- ifstream:用于读取文件中的数据;
- ofstream:用于向文件中写入数据;
- iostream:继承自 istream 和 ostream 类,因为该类的功能兼两者于一身,既能用于输入,也能用于输出;
- fstream:兼 ifstream 和 ofstream 类功能于一身,既能读取文件中的数据,又能向文件中写入数据。
其实cin就是istream类的对象,cout就是ostream的对象。都声明在<iostream>
中,除此之外,这个头文件中还声明ostream类对象,分别为cerr和clog。cerr是用来输出警告和错误信息,clog是用来输出程序执行过程中的日志信息。
cout支持重定向,clog和cerr不支持重定向,只能将数据输出到屏幕上。这种在c++提前创建好的对象叫做内置对象,可以直接使用。
输入输出重定向
重定向的方式有三种
freopen()
函数实现重定向:freopen()
定义在<stdio.h>
中,是标准库中的函数,专门用于重定向输入流和输出流。
string name, url;
//将标准输入流重定向到 in.txt 文件
freopen("in.txt", "r", stdin);
cin >> name >> url;
//将标准输出重定向到 out.txt文件
freopen("out.txt", "w", stdout);
cout << name << "\n" << url;
rdbuf()
函数实现重定向:rdbuf()
函数定义在<ios>
头文件中,用于实现输入输出流的重定向。ios是istream和ostream的基类,所以cin和cout可以直接调用该函数实现重定向。
streambuf * rdbuf() const;//仅是返回一个指向当前流缓冲区的指针
streambuf * rdbuf(streambuf * sb);//用于将 sb 指向的缓冲区设置为当前流的新缓冲区,并返回一个指向旧缓冲区的对象
ifstream fin("in.txt");//打开 in.txt 文件,等待读取
ofstream fout("out.txt");//打开 out.txt 文件,等待写入
oldcin = cin.rdbuf(fin.rdbuf());//用 rdbuf() 重新定向,返回旧输入流缓冲区指针
oldcout = cout.rdbuf(fout.rdbuf());//用 rdbuf() 重新定向,返回旧输出流缓冲区指针
- 通过控制台实现重定向:在运行程序的时候,加上
<in.txt >out.txt
,这种参数<in.txt
对程序中的 cin 输入流做了重定向,同时还用>out.txt
对程序中的 cout 输出流做了重定向。
输出缓冲区
每一个流都管理一个缓冲区,用来保存程序读写的数据。有了缓冲机制,操作系统就可以将程序的多个输出组合成单一的系统级写操作。因为写操作比较耗时,允许操作系统将多个输出操作组合作为单一的设备写操作可以带来很大的性能提升。
刷新缓冲(数据真正写到输出设备或文件)的原因如下:
- 程序正常结束,作为main函数的return操作的一部分,缓冲刷新被执行。
- 缓冲区满的时候,需要刷新缓冲,然后新的数据才能继续写入缓冲区。
- 使用endl等操作符来刷新缓冲区。
- 每个输出操作之后,可以使用操作符unitbuf来设置流的内部状态,来清空缓冲区。默认情况下,cerr是设置unitbuf,所以cerr是立即刷新的。
- 一个输出流可能被关联到另一个流,在这种情况下,当读写被关联的流的时候,关联到的流的缓冲区会被刷新。
除了endl操作符可以刷新缓冲区外,还有flush和ends。flush刷新缓冲区但是不输出任何的字符。ends向缓冲区插入一个空字符,然后刷新缓冲区。
当一个输入流被关联到一个输出流的时候,任何试图从输出流读取数据的操作都会先刷新关联的输出流。标准库将cout和cin关联在一起。tie()
函数可以用来绑定输出流,有两个重载版本。
ostream* tie() const;//返回指向绑定的输出流的指针
ostream* tie(ostream* os);//将os指向的输出流绑定在该对象上,并返回上一个绑定的输出流指针
cin.tie(&cout); //仅仅是用来展示,标准库已经将 cin 和 cout 关联在一起
//old_tie 指向当前关联到 cin 的流(如果有的话)
ostream *old_tie = cin.tie(nullptr); // cin 不再与其他流关联
//将 cin 与 cerr 关联,这不是一个好主意,因为 cin 应该关联到 cout
cin.tie(&cerr); //读取 cin 会刷新 cerr 而不是 cout
cin.tie(old_tie); //重建 cin 和 cout 间的正常关联
读取单个字符:get()
是istream类的成员函数,从输出流中读入一个字符,碰到输入的末尾,则返回值为EOF。EOF是iostream类中定义的一个整形常量,值为-1。
读入一行字符串:get()
是istream类的成员函数,有两个重载版本:
istream & getline(char* buf, int bufSize);
istream & getline(char* buf, int bufSize, char delim);
第一版本从输入流中读取bufsize-1个字符到缓冲区buf,或者直到遇到换行符为止,函数会自动在buf中读取数据的结尾的结尾添加\0
。
第二个版本和第一个的区别在于,第一个版本是读到\0
,为止,第二个版本是读到delim字符为止。\n
或delim都不会被读到buf,但会被从输出流中取走。
如果输入流中\n
和delim之前的字符个数达到或者超过bufsize,就会导致读入出错,结果就是本次读入已经完成,但是之后的读入都会失败。
如何忽略指定字符:ignore()
是istream类的成员函数,函数原型是istream & ignore(int n =1, int delim = EOF);
,这个函数是跳过指定输入流中的n个字符,默认跳过一个字符。
查看输入流中的下一个字符:peek()
是istream类的成员函数,函数原型是int peek()
,peek函数返回输入流中的下一个字符,但是并不从输入流中取走字符。在输入流已经结束的情况下,peek返回EOF。
判断输入结束: 将标准输入重定向为文件后,cin可以从文件中读入数据,在输入数据的多少不知道也没有结束标志的情况下,可以在控制台输入特殊的字符,表示结束。在linux中输入ctrl+D表示结束,在windows中输出ctrl+Z然后按回车表示结束。
cin如何判断输入结束:判断控制台读取结束:不管是文件末尾,还是ctrl+z或者ctrl+D,都是结束的标志,cin在正常读取的时候返回true,遇到结束标志时返回false,所以可以根据cin的返回值来判断是否读取结束。
文件操作
文件流
从数据储存的角度来看,所有文件的本质是一样的,都是由一个个的字节组成。除了纯文本文件外,图像、视频、可执行文件一般称为二进制文件。
文件流类主要是标准库中提供的三个类用于实现文件操作。ifstream
、ofstream
、fstream
,ifstream
和ofstream
是从istream
和ostream
派生而来,所以这三个类分别拥有istream
、ostream
的全部成员方法。
open&close
在对文件操作之前,需要先将文件打开。这样保证通过指定文件名,建立起文件和文件流对象的关联,以后要对文件进行操作时,就可以通过与之关联的流对象进行。其次就是在打开文件的时候可以指定用什么方式打开。
打开文件的方式有两种:
- 使用流对象的open成员函数打开文件。
void open(const char* szFileName, int mode)
,第一个参数是文件名的指针,第二个参数是打开文件的方式。通过open成员函数打开文件的时候,可以通过使用返回值来判断,成功就是true。 - 定义文件流对象时,通过构造函数打开文件。
ifstream::ifstream (const char* szFileName, int mode = ios::in, int);
,第一个参数是指向文件名的指针,第二个参数是打开文件的方式,第三个参数一般不用。
文件文本和二进制文本区别:在物理上来看,二进制文件和字符文件没有什么区别,都是以二进制储存在磁盘上的。文本文件采用的是ASCII、UTF-8等字符编码,文本编辑器可以识别出这些编码格式,并将编码值转换成字符展示出来。文本方式和二进制方式并没有本质上的区别,只是对于换行符的处理不同。在linux平台打开二进制文件和文本文件没有区别,在windows平台,文本文件是将连在一起的\r\n
作为换行符的,如果以文本文件打开文件读取文件时候,程序会将文件中的所有\r\n
去掉前面的\r
。
使用open打开文件是文件流对象和文件之间建立关联的过程,close就是切断文件流对象和文件之间的关联,但是该文件流没有销毁。
打开的文件一定要用close关闭。flush可以刷新缓冲区,因为缓冲区只有在满了或者文件关闭的时候才会数据写入文件,但是使用flush可以刷新输出流缓冲区,将数据写入文件。
read&write
以二进制读写数据可以节约空间,同时也方便查找,因为每个数据都是占用相同的大小空间。
要以二进制读写数据就不能再使用<<
和>>
来读写数据,需要使用read和write,其中read方法用于以二进制形式从文件中读取数据,write方法用于以二进制形式将数据写入文件。
ostream & write(char* buffer, int count);
,
istream & read(char* buffer, int count);
,
get&put
逐个读取文件中储存的字符或者逐个将字符存储到文件中,可以使用get和put。
由于文件存放在硬盘中,硬盘的访问速度远远低于内存,如果每次写一个直接都要访问硬盘的话,那么读写速度就会变得很慢,所以操作系统在接收到put或者get的请求的时候,就会先将指定字符储存到一块内存中或者从硬盘中读取一块数据存放在一块内存中(文件流输出缓冲区、文件流输入缓冲区),等刷新缓冲区的时候将一块数据一起写到硬盘中或者下次要获取数据的时候从文件流输入缓冲区中直接获取。
使用getline()
方法可以从cin输入流缓冲区中读取一行字符串,还可以读取指定文件中的一行数据。
移动和获取文件读取指针
在读写文件时,有时候想要直接跳到文件中的某处开始读写,就需要移动文件读写指针,然后在进行读写操作。
ostream & seekp (int offset, int mode);//设置文件读指针的位置
istream & seekg (int offset, int mode);//设置文件写指针的位置
mode有三种选项:
- ios::beg指向文件开始后的offset字节处,offset等于0表示从文件开头,在这种情况下,offset需要为非负数。
- ios::cur指向当前位置偏移offset字节处.
- ios::end指向从文件末尾处偏移offset字节处。
tellg()
和tellp()
可以获取当前指针的位置。