mxxhcm's blog

  • 首页

  • 标签

  • 分类

  • 归档

UNIX standard and implement

发表于 2019-11-11 | 更新于 2019-11-18 | 分类于 UNIX

UNIX standard

ISO C

ISO C是国际标准化组织给出的C语言的标准。它包含两部分:C语言的语法和语义,标准库。但是标准只是给出了C标准函数的原型和功能,并没有给出他们的实现。具体的实现由编译器决定,只要编译器声称它们支持ISO C标准,那么这个编译器就必须严格遵守ISO C标准中的各项规定。
ISO C标准现在由ISO/IEC的C程序国际标准工作组维护和开发,该工作组称为ISO/IEC JTC1/SC22/WG14,简称WG14。ISO C标准的目的是提供C程序的可移植性,使其能适合大量不同的操作系统,而不仅仅是适合UNIX系统。
根据C语言的发展来说:
1972年,丹里斯发明了C语言,这个版本的C语言叫做K&R C。
ISO C的前身是ANSI C,1989年,美国国家标准学会(ANSI)提出了ANSI标准X3.159-1989,这个标准就是ANSI C,它也被采纳为国际标准ISO/IEC 9899:1990,也就是ISO C90。其中IEC是国际电子技术委员会的缩写。后来陆续有了ISO C99,ISO C11等标准。

按照ISO标准定义的头文件,可以将ISO C库分为24个区。下面要介绍的POSIX.1标准包含ISO C的头文件以及另外一些头文件。

IEEE POSIX

POSIX是由国际电气和电子工程学会制定的标准族。POSIX是可移植操作系统接口(Portable Operating System Interface)。它原来只是IEEE操作系统接口的标准1003.1,后来扩展成了许多其他标准和标准草案,如shell和实用程序等。
和本书相关的是操作系统接口标准,它的目的是提高应用程序在各种UNIX操作系统之间的可移植性。它定义了符合POSIX的操作系统必须提供的各种服务,但是它并不局限于UNIX和类UNIX类的系统。
由于1003.1标准只给出了接口而不是一种实现,所以并不区分系统调用和库函数。1988版本的1003.1标准,即1003.1-1988递交给了ISO,最后作为IEEE标准1003.1-1990正式出版,这就是国际标准ISO/IEC 9945-1:1990,通常也称为POSIX.1。

Single UNIX Specification

单一UNIX规范(SUS)是POSIX.1标准的一个超集,它对POSIX.1进行了扩展,POSIX.1相当于SUS的基本规范部分。
POSIX.1中的X/Open系统接口(XSI)选项描述了可选的接口,也定义了遵循XSI的实现必须支持POSIX1的哪些可选部分,这些必须支持的部分包括:文件同步,线程栈地址,长度属性,线程进程共享同步以及_XOPEN_UNIX符号常量,只有遵循SXI的实现才能称为UNIX系统。

UNIX implement

  1. SVR4
  2. 4.4BSD
  3. FreeBSD
  4. Linux
  5. Mac OS X
  6. Solaris
  7. 其他UNIX系统

标准和实现的关系

标准其实是任一实际系统的子集。

限制

UNIX实现定义了很多幻数和常量。已经有若干种可以移植的方法确定这些幻数和具体实现定义的限制。以下两种类型的限制是必须的:

  1. 编译时限制,比如int的最大值是什么。
  2. 运行时限制,比如文件名的字符,这个只有在我们运行程序时才能知道。

编译时限制可以在头文件中定义。程序在编译时可以包含这些头文件,运行时限制需要进程调用另一个函数获得限制值。
但是有时候可能某些限制在给定的实现中是固定的,而在另一个实现中是变动的。比如文件的名字。为了解决这类问题,提供了以下三种限制:

  1. 编译时限制
  2. 与文件和目录无关的运行时限制(sysconf函数)
  3. 与文件和目录有关的运行时限制(pathconf函数和fpathconf函数)

ISO C限制

包含在<limits.h>头文件中,ISO C标准给出了各种类型的最小范围,每个实现必须满足这个最小范围,具体取多少由实现决定。常见的比如int, float,char等的最大值和最小值。以及可同时打开的标准I/O流的最小个数。

POSIX限制

POSIX.1定义了大量限制和常量,我们只关心和基本POSIX.1接口有关的部分。它们被分为以下7类:

  1. 数值限制
  2. 最小值
  3. 最大值
  4. 运行时可以增加的值
  5. 运行时不变值
  6. 其他不变值
  7. 路径可变名

这些限制有些在<limits.h>中定义,其余的限制按具体条件可以定义,可以不定义。

XSI限制

  1. 最小值
  2. 运行时不变值

sysconf, pathconf和fpathconf

如何获得一个特定系统中实际支持的限制值?

  1. 某些限制值在编译时是可以使用的,而有些限制值只有在运行时才能确定。
  2. 也有某些限制值在一个给定的系统中可能是不会改变的,而其他限制值可能会改变,因为它们与文件和目录相关。

运行时限制可以通过下面三个函数之一获得:

1
2
3
4
5
#include <unistd.h>
long sysconf(int name);
long pathconf(const char *pathname, int name);
long fpathconf(int fd, int name);
// 所有函数如果成功,返回相应值,出错,返回-1

选项

基本系统数据类型

头文件<sys/types.h>定义了许多和实现相关的数据类型,它们被称为基本系统数据类型。这些数据类型都是用C的typedef定义的,大多数以_t结尾。常见的如下:

  • clock_t
  • gid_t
  • uid_t
  • pid_t
  • pthread_t
  • ptrdiff_t
  • size_t
  • ssize_t
  • time_t

标准之间的冲突

如果出现冲突,POSIX.1服从ISO C标准。

参考文献

1.《APUE》
2.https://www.zhihu.com/question/40175738/answer/154308906

UNIX basic

发表于 2019-11-11 | 更新于 2019-12-17 | 分类于 UNIX

UNIX system

UNIX是一类操作系统,所有的操作系统都为它们运行的程序提供服务。典型的服务包括:执行新程序,读写文件,分配存储区域,获得当前时间等等。

UNIX体系结构

严格意义上来说,可以把操作系统定义为一种系统软件,它控制计算机硬件资源,提供程序运行环境,这种软件称为内核。从广义上来说,操作系统包含了内核和其它一些软件,这些软件使得计算机能够发挥作用,其他软件包括应用程序,shell和公共函数库等。比如,Linux是GNU操作系统的内核,我们有时候也把GNU/Linux操作系统称为Linux,所以Linux可以表示两层含义,一种是内核,一种是操作系统。
内核的接口称为系统调用(system call),公用函数库在系统调用接口之上,应用程序可以使用公用函数库,也可以使用系统调用。shell是一个特殊的应用程序,它为运行其他应用程序提供了一个接口。
系统调用提供了访问内核的接口,公用函数库构建在系统调用上,应用程序可以构建在公用函数库上,也可以构建在shell和系统调用之上,shell建立在系统调用之上。

登录(Login)

用户名(username),用户组(group)和密码(passwd)

/etc/group和/etc/passwd文件分别给出了user的group和passwd相关信息,这些文件的具体格式可以使用man 5 passwd和man 5 group查看。

shell

shell是一个命令行解释器,它读取用户输入,然后执行命令。shell的用户输入通常来自终端,这种是交互式shell;有时shell的输入也可以来自文件,这种文件叫做shell脚本。
常见系统的shell有:

  • Bourne shell,/bin/sh,是Steve Bourne在贝尔实验室开发的shell,几乎所有的UNIX系统都支持这种shell,
  • Bourne-again shell, /bin/bash,GNU shell,所有的Linux系统都提供这种shell,它的设计遵循了POSIX标准,同时保留了和Bourne shell的兼容性。更多关于/bin/bash的内容可以参考linux /bin/bash。
  • C shell,/bin/csh,Bill Joy在博客里开发的shell,所有BSD版本都提供这种shell
  • Korn shell,/bin/ksh,贝尔实验室的David Korn开发的,在大多数UNIX系统上运行,与Bourne shell向上兼容。
  • TENEX C shell,/bin/tcsh,是C shell的加强版本,它从TENEX系统借鉴而来,常用来替换C shell。

不同的Linux版本使用不同的默认shell,一些Linux默认使用Bourne-again shell,另外一些使用BSD对Bourne shell的替代品dash(Debian Almquist shell)。
FreeBSD的默认用户shell衍生于Debian Almquist shell。
Mac OS X的默认shell是Bourne-again shell。
Solaris继承了BSD和System V,它提供了所有的shell。

文件和目录(File and dir)

文件系统

UNIX文件系统是文件和目录的一种树状结构,所有目录和文件的起点是根目录(root dir),用"/"表示。

文件名

目录中的各个名字称为文件名,只有"/“和空格不能出现在文件名中。”/“用来分隔构成路径名的各文件名,而空格用来终止一个路径名。
创建新的目录时会自动创建”.“目录指向当前目录和”…“指向父目录,在根目录中,”.“和”…"都指向根目录。

目录名

由斜线分隔的一个或者多个文件名组成的序列构成路径名,以斜线开头的路径名称为绝对路径名,否则是相对路径名。"/"是一个特殊的绝对路径名,它不包含文件名。

工作目录

相对路径名都是从工作目录开始解释的。在shell中可以通过cd更改工作目录,进程可以通过chdir更改工作目录。

起始目录(用户主目录)

用户登录后,工作目录默认设置为用户主目录,可以从/etc/passwd文件查看。

输入和输出(Input and Output)

文件描述符

文件描述符是一个小的非负整数,内核用它来标示一个进程正在访问的文件。当内核打开或者创建一个文件时,都会返回一个文件描述符。在读写文件时,可以使用这个文件描述符。

标准输入,标准输出和标准错误

在shell中运行一个新程序时,所有的shell都会为这个程序打开三个文件描述符,标准输入,标准输出和标准错误。如果没有指定的话,它们都链接向终端。可以通过重定向将它们链接到文件。

不带缓冲区的I/O

函数open,read,read,lseek和close提供了不带缓冲的I/O。这些函数都使用文件描述符。
头文件<unistd.h>包含了两个常量STDIN_FILENO和STDOUT_FILENO,分别指向标准输入和标准输出的文件描述符。它们是POSIX标准的一部分,在POSXI标准中分别是0和1,但是为了可读性,一般不用它们的具体值。

标准I/O

标准I/O函数为那些不带缓冲的I/O函数提供了一个带缓冲的借口。使用标准I/O不用担心如何选取最佳的缓冲区大小,还简化了对输入行的处理。

程序和进程(Process)

程序

程序是一个存储在磁盘上的某个目录中的可执行文件。内核使用exec函数将程序读入内存,并执行该程序。

进程和进程ID(PID)

程序的执行实例被称为进程(process)。每一个进程都有一个唯一的数字标示符,叫做进程ID(process ID, PID),PID是一个非负整数。可以使用getpid函数获得PID,getpid返回一个pid_t类型,我们不知道它的大小,但是标准保证它能存放在一个长整形中,所以可以把它强制转换成它可能会用到的最大数据类型即长整形,提高程序的可移植性。

进程控制

有三个用于进程控制的主要函数,他们分别是fork,exec和waitpid。

  • fork创建一个新进程,新进程是调用进程的一个副本,称调用进程为父进程,新创建的进程为子进程。fork对父进程返回子进程的进程ID(一个非负整数),对子进程则返回0。
  • execlp函数要求参数以null结束而不是换行符结束。
  • 子进程中调用execlp执行从标准输入读入的命令,用新的程序文件代替子进程原先执行的程序文件。fork和exec两者组合就是某些操作系统所称的spawn一个新进程。在UNIX系统中,这两部分分成了两个独立的函数。
  • 子进程调用execlp执行新的程序文件,父进程希望等待子进程终止,这是通过waitpid实现的,waitpid接收要等待进程的ID,以及接收一个整形的地址,用来存放子进程的终止状态。出错返回-1,否则返回state改变的子进程的PID,或者返回0(子进程的状态没有改变)。

线程(thread)和线程I/O

  1. 通常来说一个进程只有一个控制线程,即某一时刻执行的一组机器指令。对于某些问题,如果有多个控制线程分别多用于它的不同部分,可以使得问题变得更加容易。也可以充分利用多处理器系统的并行能力。
  2. 一个进程内的所有线程共享同一地址空间,文件描述符,栈以及和进程相关的属性。因为线程能够访问同一个存储区域,所以线程在访问共享数据时需要采取同步措施避免不一致性。
  3. 线程也有ID,但是线程ID只在它所属的进程内起作用。一个进程中的线程ID在另一个进程中没有意义。当一个进程中对某个特定线程进程处理时,可以使用线程ID引用它。

出错处理

当UNIX系统函数出错时,通常会返回一个负值,整形变量errno通常被设置为具有特定信息的值。比如对于open函数,errno大约有15种不同的值(文件不存在,权限问题),成功执行返回一个非负文件描述符,出错则返回-1。另外有一些函数对于出错使用约定值而不是返回值。比如返回对象指针的函数,出错时会返回一个NULL指针。
头文件<errno.h>定义了errno以及可以赋值给它的各种常量。这些常量都以字符E开头。可以使用man 2 intro列出这些常量,在linux中使用man 3 errno列出这些常量。

**关于errno的两条规则:

  1. 如果没有出错,errno的值不会被清除,所以,只有在函数的返回值指明出错的时候,errno的值才是有意义的,否则不要使用它;
  2. 任何函数都不会将errno的值设置为0,而且<errno.h>头文件中的任何常量都不等于0。**

出错恢复

可以将<errno.h>中定义的各种出错分为两类:致命性的和非致命性的。
对于致命性的错误,无法恢复,可以输出错误信息帮助用户处理。
对于非致命的错误,可以妥善的进行处理。大多数非致命性出错都是暂时的,比如因为资源短缺。常见的与资源相关的非致命性出错包含:EAGAIN,ENFILE,ENOBUFS,ENOLCK,ENOSPC,EWOULDBLOCK,有时候ENOMEM也是非致命性出错。当EBUSY指明共享资源正在被使用时,也可以将它作为非致命性出错。当EINTR中断一个慢速调用时,也可将它作为非致命性出错处理。对于资源相关的非致命性出错,最典型的恢复操作是延迟一段时间重试。

用户标识

用户ID(UID)

用于标识不同的用户。可以在口令文件/etc/passwd中找到用户的UID。root用户的UID是0。

组ID(GID)

用于标识不同的group。口令文件/etc/passwd中也包含了用户的GID。组文件将组名映射为数值的GID。

为什么使用数值的UID和GID,对于磁盘上的每个文件,都应该存储该文件所有者的GID和PID,存储这两个值只需要4个字节(假设每个都用双字节的整形存放,早期的UNIX系统使用16位整数表示UID和GID,而现在的UNIX系统使用32位整数表示UID和GID。
)。如果使用ASCII user name和group name,需要更多的磁盘空间。在进行权限检验时,整形也比字符串更快。
但是对于用户来说,使用ASCII name比较方便,所以在/etc/passwd和/etc/group中存放了UID和user name以及GID和group name的映射。

附属组ID

每个登录名除了/etc/passwd指定的一个group外,还可以属于其他的group,大多数系统至少支持16个附属组,通过查找/etc/group中有该登录名的前16个项作为它的附属组ID。

信号(signal)

Signal用于通知进程发生了某种特殊情况。比如进程在执行除法操作的时候,发生了除0操作,内核会将SIGFPE signal发送给进程。对于每一个signal,进程有以下三种方式进行处理:

  1. 忽略某个signal。不推荐这种处理方式,如果发生了除0操作,就会出错。
  2. 按系统默认方式处理。比如除0操作,系统默认是终止进程。
  3. 提供一个函数,某个signal发生时,调用该函数,这称为捕捉signal。

很多情况都会产生signal,通过键盘有两种产生signal的方法:

  • 中断键(interrupt key),通常是ctrl+C或者Delete键和退出建(quit key),通常是Ctrl+\ 键,这两个signal用于中断当前运行的进程。
  • 调用kill函数,向进程发送一个signal,注意我们必须是root用户或者这个进程的所有者。

捕获signal示例

在bash中运行程序时,使用中断键,相应的程序会终止。为什么会发生这种结果,对于中断信号(SIGINT)的系统默认动作是终止进程,因为进程没有告诉系统内核该怎么处理,所以系统按照默认方式终止该进程。
为了捕获该信号,程序需要调用signal函数,其中指定了产生SIGINT信号时要调用的函数的名字。

时间(time)

UNIX系统使用过两种不同的时间值。

  1. 日历时间。协调世界时(Coordinated Universal Time, UTC),即从1970年1月1日00:00:00这个时间开始经过的累计秒数。
    系统基本数据类型time_t用于保存这种时间值。
  2. 进程时间。也称为CPU时间,用来度量进程使用的CPU资源。进程时间用始终滴答计算,每秒钟曾经取过50,60或者100个时钟滴答。
    系统基本数据类型clock_t保存这种时间值。

当度量一个进程的执行时间时,UNIX系统为一个进程维护了三个进程时间值:

  • 时钟时间,进程运行的时间总量,它的值和系统中同时运行的进程数有关。APUE中用到的时钟时间指的都是系统中没有其他活动时进行度量的。其实就是这个进程从开始到结束总共花了多长时间,包括阻塞,等待和运行的时间。
  • 用户CPU时间,执行用户指令所用的时间。
  • 系统CPU时间,为该进程执行内核程序所经历的时间。

用户CPU时间和系统CPU时间被称为CPU时间,可以使用time命令获得一个进程的时钟时间,用户CPU时间和系统CPU时间。比如:

1
2
~$:time ls
~$:time man ls

系统调用(system call)和库函数(library function)

系统调用

所有的操作系统都提供多种服务的入口点,通过这些入口点向内核请求服务,这些入口点被称为系统调用(system call)。系统调用处于kernel mode,一些任务只能在kernel mode运行。比如和硬件的交互,系统调用使得用户mode的进程可以通过系统调用进入kernel mode,从而实现和硬件的交互。。
系统调用接口可以在man的第二部分查看,它是用C语言定义的,与具体系统如何调用一个系统调用的实现技术无关。这些和早期的操作系统按照传统方式用机器的汇编语言定义内核入口点。
UNIX使用的方法是为每个系统调用在标准C库中设置一个同名函数。用户进程使用标准C调用相应的函数,这些函数又根据系统调用调用相应的内核服务。

库函数

库函数可以在man手册的第三部分查看,第三部分定义了程序员可以使用的通用库函数。库函数可以调用系统调用,也可以不调用系统调用,比如read函数会调用系统调用,而atoi等并不使用任何系统调用。

系统调用和库函数之间的关系

  1. 从实现角度来看,系统调用和库函数有着根本的区别,系统调用处于内核mode,库函数属于用户mode。
  2. 从用户应用角度考虑,可以把系统调用看做C函数,使用系统调用还是库函数不重要,它们都是为应用程序提供服务的。
  3. C函数只是系统调用和库函数的一种实现,系统调用和库函数都可以以其他方式实现。
  4. 系统调用通常只是提供一种最小接口,而库函数实现更复杂的功能。
  5. 库函数可以被替换,但是系统调用通常是不能替换的。
  6. 库函数可以调用系统调用,也可以不调用系统调用。
  7. 应用程序既可以调用库函数也可以调用系统调用。
  8. 进程控制系统调用(fork, exec和wait)等通常由应用程序直接调用。为了简化一些常见情况,UNIX也提供了一些库函数,如system和popen。
  9. 库函数链接到用户程序,在user space执行,而syste call没有链接到用户程序,在kernel space执行
  10. 库函数的执行时间被计算为user level time,而system call的执行事件算作system time的一部分。
  11. 库函数可以简单的进行debug,而系统调用不能debug,因为它们被kernel执行。

对于第4条,可以考虑以下例子:
sbrk(2)是分配存储空间的UNIX系统调用,它按照指定字节数增加或者减少进程地址空间。如何管理进程的地址空间由进程决定。
malloc(3)是公用函数库中的一个存储分配空间函数,它负责进行进程的存储地址管理。
我们可以自己实现一个malloc,但是它很有可能还要使用sbrk(2)。内核中的系统调用ssbrk是系统层面的空间分配,而库函数malloc是在用户层面进行操作。

参考文献

1.《APUE》第三版
2.https://www.thegeekstuff.com/2012/07/system-calls-library-functions/
3.https://stackoverflow.com/questions/29816791/what-is-the-difference-between-system-call-and-library-call

APUE-overview

发表于 2019-11-11 | 分类于 UNIX

APUE是什么

APUE是Advandaced Programming in the UNIX environment的简称,即UNIX高级环境编程。
很多人都把这本书和UNP(UNIX Network Programming)当做UNIX编程的神书。一直想拜读这两本书,但是每次翻开之后就放弃了,因为看不懂它在讲些什么,这篇博客简单介绍了APUE到底是什么,它能用来干什么。

APUE包含的内容

UNIX系统提供了两类程序设计接口:

  1. 系统调用接口:UNIX为程序运行提供的大量服务-打开文件,读文件,启动一个新程序,分配存储区以及获得当前时间等等,这些服务被称为系统调用接口(system call interface)。
  2. 标准C库:提供大量广泛用于C程序中的函数(格式化输入出入,字符串比较等等)。

APUE这本书并不是介绍UNIX中有哪些系统调用接口和库函数,这些可以从《UNIX程序员手册》中找到,APUE给出的是这些系统调用接口和库函数该怎么使用,以及它们的基本原理。

UNIX标准

20世纪80年代,有各个版本的UNIX,为了让这些UNIX版本统一起来,人们制定了数个国际标准,包含C程序设计的ANSI C标准,IEEE POSIX标准,以及X/Open可移植性等等。

APUE样例

APUE中给出的所有示例代码,都是ANSI C编写的。这些程序的下载地址。

参考文献

1.《APUE》第三版

C++ generic algorithm

发表于 2019-11-10 | 更新于 2020-01-08 | 分类于 C/C++

泛型算法

头文件<algorithm>定义了许多通用算法,而头文件<numeric>定义了一组数值泛型算法。
算法并不依赖于容器,即算法不直接操作容器,而是运行于迭代器之上,执行迭代器的操作。因此算法永远不会改变底层容器的大小,但是它可能改变容器中保存的元素,也可能在容器内移动元素,但永远不会直接添加或者删除元素。
标准库定义了一类特殊的迭代器,叫做插入器(inserter),插入器可以向容器添加元素。给插入器赋值的时候,它们会在底层的容器上执行插入操作。因此,当一个算法操作这样的迭代器时,迭代器可以完成向容器添加元素的效果,但是算法自身不会直接操作容器。

绝大部分算法的都对一个范围内的元素进行操作,这个范围被称为输入范围,接收输入范围的算法总是使用前两个参数表示这个范围。可以将它们分为:

  • 只读算法,如find, find_if, count, count_if accumulate,equal等。
  • 写容器元素的算法,如fill,fill_n,copy,replace,replace_copy等。
  • 重排容器元素的算法,如sort, unique等。

定制操作

这一节主要介绍了如何向算法传递可调用对象。总共有三种方法:

  1. predicate。predicate是一个可调用的表达式,返回结果是一个能用作条件的值。C++ 使用了unary predicate和binary predicate。
  2. lambda表达式,具体的可以查看。
  3. bind 绑定参数。

再谈迭代器

这一节介绍了四种特殊的迭代器:

  1. insert iterator
  2. iostream iterator
  3. reverse iterator
  4. move iterator

迭代器类别

算法形参模式

算法命令规范

特定容器算法

list和forward_list单独定义了sort, merge, remove, reverse和unique。
因为通用的sort需要使用random_access_iterator,所以不能用于list和forward_list。而上述的其他算法的通用版本可以用于list和forwrad_list,但是代价太高了。这些算法需要交换输入序列中的算法,list和forward_list可以仅仅通过改变元素之间的链接而不是真的交换它们的值实现更快的交换,这样的性能比通用的版本要更好一些。list和froward_list还有一个特殊的splice算法。

注意,链表特有版本的算法会改变底层的容器。

参考文献

1.《C++ Primer》第五版中文版

C++ template

发表于 2019-11-10 | 更新于 2020-02-25 | 分类于 C/C++

模板面试知识点

  1. 模板特化和偏特化[2]。
  2. 函数模板和类模板的定义通常需要放在头文件中。为了生成一个实例化版本,编译器需要掌握函数模板或者类模板成员函数的定义[3]。
  3. 函数重载和模板特化的关系。模板特化不影响函数匹配,实际上,我们是替编译器实现了重载之后的某个特殊类型的实例化。

一些函数

  1. remove_reference,可以将一个引用类型(包括左值引用和右值引用)变成非引用类型,通过类模板特化完成。
  2. std::move,将一个左值转换成右值,或者保持右值不变。通过static_cast完成。
  3. std::forward<T>(arg)。
    1
    2
    3
    4
    5
    template<typename Type>
    void func(Type && arg)
    {
    another_func(std::forward<Type>(arg));
    }

上述代码会做到将func的实参原封不动的(保持所有属性)转发到another_func中,具体的过程:首先根据引用折叠和右值引用参数推断出Type的类型,如果arg是右值,Type是普通类型,如果arg是左值,则Type是引用的右值,通过引用折叠,得到Type是一个左值引用。推断出Type的类型之后,std::forward返回的是Type的&&,再和arg进行引用折叠,得到相应的左值或者右值。

模板定义

模板参数列表

模板定义以关键字template开始,后跟一个模板参数列表,是一个逗号分隔的一个或者多个模板参数的列表,用<和>分隔开来。模板参数可以是模板类型参数和非类型模板参数。

模板类型参数

一个模板类型参数表示一个类型,类型参数前需要加上class或者typename关键字,它们是等价的。

非类型模板参数

而非类型模板参数是一个值,而不是一个类型,通过一个特定的类型名指定非类型参数。

模板参数列表的作用很像函数参数列表。函数参数列表定义了若干特定类型的局部变量,但是没有指出如何初始化它们。运行时,需要调用者提供实参来初始化形参。而模板参数表示在类或者函数定义中用到的类型或者值。使用模板时,我们需要显式或者隐式的指定模板实参,将它绑定到模板参数上。

模板种类

函数模板

调用函数模板时,编译器会用函数实参推断模板实参。

类模板

编译器不能为类模板推断模板类型参数,为了使用类模板,必须在模板名后的尖括号提供模板实参列表代替模板参数。

  1. 实例化类模板。
    使用类模板时,需要提供显式模板实参列表,编译器使用这些模板实参进行实例化。
  2. 类模板的成员函数。
    定义在类模板之外的成员函数必须以关键字template开始,后接类模板参数列表。在类外定义一个成员时,不仅要说明成员属于哪个类,从一个模板生成的类的名字中必须包含它的模板实参(比如Blob<T>)。
  3. 类模板成员函数的实例化。
    对于一个实例化了的类模板,它的成员只有在使用时才被实例化,所以即使某种类型不完全符合模板操作的类型,也能用这个类型实例化类。
  4. 在类的作用域内简化模板类名的使用。
    在类模板自己的作用域内,不需要使用Blob<T>,使用Blob即可。
  5. 类模板和友元。
    仅仅每个实例类型之间互为友元。(template_friend_1.cpp)
    一个类可以将另一个模板的所有实例都声明为友元。(template_friend_2.cpp)
    一个类可以是一个类模板所有实例的友元。(template_friend_3.cpp)
    一个类模板将另一个类模板的所有实例都声明为友元。(template_friend_4.cpp)
    一个模板将模板类型参数声明为友元。
  6. 模板类型别名。
    使用using可以为类模板定义一个别名。
  7. 类模板的static成员。
    每个实例都有一个自己的static成员。

模板参数

  1. 模板参数和作用域。
    模板内的变量名不能和模板参数名冲突。
  2. 使用类的类型成员。
    默认情况下,假定通过作用域运算符访问的是名字不是类型。如果需要访问类型的话,必须使用typename关键字告诉编译器这是一个类型。
  3. 默认模板实参。
    C++ 11之后,可以为函数和类模板都提供默认实参。在使用类模板的时候,必须在模板名后加上尖括号,如果一个类模板的所有模板参数都有默认实参,而且想要使用默认实参,需要使用`<>。

成员模板

一个类(普通类或者类模板)都可以包含本身是模板的函数,这种成员被称为成员模板,成员模板不能是虚函数。

  1. 普通类的成员模板。
  2. 类模板的成员模板。
    类模板和成员模板有各自的模板参数。当在类模板外定义成员模板的时候,必须同时为类模板和成员模板提供模板参数列表,类模板的参数列表在前,成员模板的参数列表在后。

实例化定义

模板在使用时才被实例化,当多个独立编译的源文件同时使用了相同的模板和模板参数列表时,可能而存在多个同一模板的示例,使用显式实例化避免这种开销。

1
2
3
4
5
extern template declaration;    //实例化声明
template declaration; //实例化定义
// 示例
extern template Blob<string>;
template Blob<string>;

实例化定义会实例化所有的成员,所以用来实例化的类型,必须能用于模板的所有成员。

效率和灵活性

shared_ptr在运行时绑定deleter,deleter不是shared_ptr的成员。
unique_ptr在编译是绑定deleter,deleter是unique_ptr的一个成员。

模板实参推断

类型转换和模板类型参数

  1. 能用于函数模板的类型转化。
    • const转换,可以将非顶层const 转换为顶层const
    • 数组或者函数转换为相应的指针。
  2. 相同模板类型参数的函数形参。
  3. 不同模板类型参数的函数形参。
  4. 正常类型转换对应于普通函数实参。

函数模板显式实参

对于一些编译器无法推断出的模板实参类型,可以指定显式模板实参。而且显式模板实参的顺序和模板参数声明的顺序一致。

尾置返回类型

可以使用尾置返回类型声明不知道返回结果的类型。

函数指针的实参推断

当参数是一个函数模板实例的地址时,程序的上下文必须满足,对于每个模板参数,能唯一确定它的类型或者值。

模板实参推断和引用

  1. 当函数参数是一个普通左值引用的时候,只能给他传递一个左值,实参可以是const,也可以不是。如果实参是const的,T会被推断成const类型。
    当函数参数是一个常量引用的时候,实参可以是任何对象。这个const是函数参数类型的一部分,而不是模板参数类型的一部分。
  2. 也可以推断出右值实参的类型。
  3. 引用折叠和右值引用参数。如果一个函数参数是指向模板参数类型的右值引用,比如T&& val,可以给val传递任意类型的实参(左值或者右值)。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用。

理解std::move

std::move的源代码如下:

1
2
3
4
5
template<typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type&&>(t);
}

所以,从代码中我们可以看出来,是可以显式的将一个左值static_cast到一个右值引用。

forward

forward指的是将实参的所有性质原封不动的转发给其他函数,包括实参类型是否是const的以及实参是左值还是右值。
**forward的返回类型是T&&。**如下代码:

1
2
3
4
5
6
7
8
9
template<Typename T>
void func(Type && arg)
{

// 首先对右值引用模板参数进行推导,如果arg是右值,那么推断出Type是普通非引用类型。如果arg是左值,推断出Type是左值引用类型。
std::forward<Type>(arg);
// 调用std::forward获得Type的右值引用,通过引用折叠,可以得到相应的左值和右值类型变量。

}

重载与模板

函数模板匹配规则,如果有多个函数都满足要求,选择其中最特殊的那个:普通函数比函数模板特殊,都是函数模板的时候,选择更特殊的那个,否则就有歧义。

可变参数模板

可变参数模板是一个接收可变数目参数的模板函数或模板类。可变数目的参数被称为参数包。存在两种参数包:模板参数包和函数参数包,模板参数包表示零个或者多个模板参数,就是typename加上… ,函数参数包表示零个或者多个函数参数,是模板类型加上…。

模板特化

函数模板特化和类模板特化。特化一个函数模板时,必须为原函数模板中的每个模板参数都提供实参,叫做全特化。类模板的特化不需要给所有的模板参数提供实参,可以指定一部分参数而不是所有参数,即偏特化。
特化的本质是实例化,和模板的重载本质上是不同的,特化不会影响函数匹配。

参考文献

1.《C++ Primer》第五版中文版
2.https://stackoverflow.com/questions/8061456/c-function-template-partial-specialization
3.https://isocpp.org/wiki/faq/templates

C++ object-oriented programming

发表于 2019-11-10 | 更新于 2020-02-06 | 分类于 C/C++

概念

  1. 类的基本思想是数据抽象和封装。数据抽象是一种依赖于接口和实现分离的编程技术。类的接口包括用户所能执行的操作;类的实现包括类的数据成员,接口的实现部分(函数体),类所需要的各种私有函数的实现。封装指的是用访问说明符(public, private, protected)实现对类的访问控制。
  2. 继承。有一个类作为基类,派生类从基类继承而来,具有基类的全部或者部分成员。
  3. 动态绑定,也叫运行时绑定。在运行时选择函数的版本。
  4. 虚函数。基类将希望派生类override的函数声明为虚函数。
  5. 类派生列表。冒号后面跟着以逗号分隔的基类列表,每个基类前面可以有三种访问说明符中的一个:public,private, protected。

什么是面向对象

面向对象程序设计的核心思想是数据抽象,继承,动态绑定。

  1. 数据抽象和封装。数据抽象依赖于接口和实现分离的编程技术。接口包括用户所能执行的操作,实现包括类的数据成员,接口实现的函数体,定义类所需要的各种私有函数。封装指的是用访问说明符(public, private, protected)实现对类的访问控制。
  2. 继承,定义相似的类型并对其相似关系建模。有一个类作为基类,派生类从基类继承而来,具有基类的全部或者部分成员。
  3. 动态绑定,也叫运行时绑定,在运行时选择函数的版本。动态绑定在一定程序上忽略相似类型的区别,用统一的方式使用它们的对象。

基类和派生类

定义基类

  1. 成员函数和继承。派生类可以继承基类的成员函数,也可以提供自己的函数对基类的函数进行覆盖(override)。基类需要将这两种成员函数分开:一种是基类希望派生类覆盖的函数,一种是基类希望派生类直接使用的函数。
    定义虚函数表明基类希望派生类覆盖的函数,在使用对象的指针或者引用调用虚函数时,这个调用会被动态绑定。而没有定义为虚函数的成员函数,解析过程发生在编译时而非运行时。
    除了构造函数之外的所有非静态函数都可以是虚函数。在基类中声明的虚函数,在派生类中无需声明为虚函数就隐式的是虚函数。
  2. 访问控制和继承。派生类可以继承基类的成员,但是派生类的成员函数不一定有权限访问继承来的成员。派生类可以访问基类的公用成员,但是无法访问基类的私有成员。protected访问运算符声明的成员表示派生类可以访问,但是其他用户无法访问的成员。

定义派生类

通过派生类列表,指出它是从哪个或者哪些基类中继承而来的。首先是一个冒号,后面跟着用逗号分隔的基类列表,每个基类前面可以有三种访问说明符中的一个:public、protected或者private,默认是private。

  1. 派生类中的虚函数。派生类可以不override他继承的虚函数,这样子派生类会直接继承基类中的版本。
    C++ 11中,不一定需要在要override的函数前加上virtual关键字,可以使用override关键字修饰,表明这个函数是override的函数。
  2. **派生类对象和派生类向基类的类型转换。可以把派生类对象当做基类对象使用,也可以将基类指针或引用绑定到派生类对象上,也可以把派生类指针用在需要基类指针的地方。**一个派生类对象包含多个组成部分:一个含有派生类自己定义的成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,就会有多个这样的子对象。一个基类的对象可以独立存在,也可以作为派生类对象的一部分存在。如果基类对象不是派生类对象的一部分,那么它只含有基类定义的成员,而不含有派生类定义的对象。而一个基类对象可能是派生类对象的一部分,也可能不是,所以不存在从基类到派生类的自动类型转换。
  3. 派生类的构造函数。派生类的构造函数中,需要调用基类的构造函数对于基类部分进行初始化。即每隔类控制它自己的成员的初始化。
  4. 派生类使用基类的成员。派生类的对象不能直接初始化基类的成员,尽管语法上是正确的。与类的对象交互必须使用类的接口。
  5. 继承与静态成员。整个继承体系中只存在每个静态成员的一个定义。
  6. 派生类的声明。派生类的声明中不能包含它的派生列表。
  7. 用作基类的类。如果要将某个类用作基类,这个类必须声明而且定义过了。
  8. final关键字防止继承的发生。用final关键字修饰的类不能用作基类,但是可以作为子类,不过这个子类不能继续作为基类被继承。

类型转换和继承

  1. **在具有继承关系的类之间,可以将基类的指针或引用绑定到派生类对象。当使用基类的引用或者指针时,引用或者指针绑定的对象可能是基类的对象,也可能是派生类的对象。**因为每个派生类都包含一个基类部分,基类的引用或者指针可以绑定到基类部分。
  2. 表达式的静态类型是变量声明时的类型或表达式生成的类型,在编译时就是已知的;动态类型是变量或者表达式表示的内存中的对象的类型,在运行时才知道。
  3. 不存在从基类向派生类的隐式类型转换。
  4. 派生类向基类的类型转换只对指针或者引用类型有效,在派生类类型和基类类型之间不存在这样的转换。当用一个派生类对象为一个基类对象初始化(调用拷贝构造函数)或者赋值(调用拷贝赋值函数)时,只有该派生类中的基类部分会被拷贝,移动或者赋值,它的派生类部分将被忽略掉。

虚函数

  1. 每一个虚函数都必须被定义,因为连编译器也不知道哪个虚函数被使用了。
  2. 对虚函数的调用可能在运行时才被解析,编译器产生的代码知道运行时才能确定应该调用哪个版本的函数。
  3. 动态绑定只有在通过指针或者引用调用虚函数时才会发生。(P537页)
  4. C++ 的多态性的根本原因是引用或指针的静态类型可以和动态类型不同。
    当使用基类的引用或者指针调用基类中定义的一个函数时,如果这个函数是虚函数,直到运行时才会决定到底执行哪个版本,这个版本取决于引用或者指针绑定的对象的真实类型。
    对于非虚函数的调用在编译时进行绑定。同样,通过对象(不是引用也不是指针)进行的函数调用(包含虚函数和非虚函数)都在编译时确定。
  5. 派生中override的虚函数必须和基类中的函数形参,返回类型完全一致,除了类的虚函数返回类型是类本身的指针或引用时是一个例外。
  6. final和override关键字。final说明这个类不能被继承,override说明这个函数是对基类中虚函数的重写。它们出现在形参列表(包括const或引用修饰符)之后。final也可以用于修饰某个函数,表示这个函数不能被override。
  7. 虚函数和默认实参。如果虚函数调用使用了默认实参,实参值由调用的静态类型决定(也就是基类虚函数定义的默认实参,和派生类定义的默认实参无关)。
  8. 可以使用作用域运算符强制调用虚函数的某个特定版本。例如,当一个派生类的虚函数需要调用它覆盖的基类的虚函数版本时。

纯虚函数和抽象基类

  1. 纯虚函数。在函数体的位置书写=0就可以将一个函数声明为纯虚函数,纯虚函数无序定义。
  2. **含有纯虚函数的类是抽象基类。**不能定义抽象基类的对象。抽象基类负责定义接口,而派生类可以覆盖接口。

访问控制和继承

每个类控制着自己成员的初始化过程,还控制着成员对于派生类来说是否可访问。

成员访问说明符

protected说明符:

  1. 对于类的用户来说是不可访问的,对派生类的成员和友元来说是可以访问的。
  2. 派生类的成员或者友元只能访问派生类对象的基类部分的protected成员,派生类对于基类对象中的protected成员没有任何访问特权。

某个类对它继承而来的成员的访问权限受到两个因素影响:一个是在基类中该成员的访问说明符,另一个是派生类的派生列表中的访问说明符(public, private, protected继承)。
**基类中的访问说明符决定基类的用户(包含派生类的成员和友元)是否能够访问它的直接基类成员,派生列表中的访问说明符决定派生类用户(包含派生类的派生类)对于基类的访问权限。**对于public继承,派生类用户对于基类的成员的访问遵循原来的访问说明符;私有继承,所有的基类成员都是private的;对于protected继承,基类的所有public成员都是protected的,派生类的成员和友元可以访问继承而来的成员,而派生类的用户不能访问。

派生类向基类转换的可访问性

一个类可能有三种用户:普通用户,类的实现者,派生类。普通用户编写的代码使用类的对象,这部分用户只能访问类的公有成员(接口),而实现者负责编写类的成员和友元代码,它们既能访问类的公有部分,也能访问类的私有部分。
派生类向基类的转换会受派生类的派生访问说明符的影响。

  1. 派生类public继承基类,用户代码可以使用派生类到基类的转换,否则不行。
  2. 派生类public, protected, private继承基类,派生类成员或者友元都可以使用派生类到基类的转换。
  3. 派生类public或者protected继承基类,派生类的派生类的成员或者友元可以使用派生类到基类的转换,否则不行。

基类的public成员,基类成员和友元可以访问,基类的普通用户可以访问,派生类的成员和友元可访问。
基类的protected成员,基类成员和友元可以访问,基类的普通用户不可以访问,派生类的成员和友元可以访问。
private,基类的成员和友元可以访问,基类的普通用户不可以访问,派生类的成员和友元不可以访问。
public继承,派生类的普通用户可以访问基类的public成员,派生类的派生类的成员和友元可以访问基类public和protected成员。
protected继承,派生类的普通用户不可以访问基类的任何成员,派生类的派生类的成员和友元可以访问基类的public和protected成员。
private继承,派生类的普通用户不可以访问基类的任何成员,和派生类的派生类成员和友元不可以访问基类的任何成员。

友元和继承

友元关系不能继承,每个类负责控制各自的访问权限。

改变个别成员的可访问性

可以使用using声明改变继承的某些成员的可访问性。

继承中的类作用域

  1. **派生类的作用域嵌套在基类的作用域之内。**所以派生类才能像使用自己的成员一样使用基类的成员。
  2. 一个对象,引用或者指针的静态类型决定了该对象的哪些成员是可见的。
  3. 和其他的作用域一样,派生类也能重用定义在它的直接基类或者间接基类的名字,定义在内层作用域的名字会隐藏在定义在外层作用域的名字,而不会重载声明在外部作用域的函数。派生类的成员会隐藏同名的类成员,可以通过作用域运算符使用隐藏的外层作用域成员。
    **为什么基类与派生类的虚函数必须有相同的形参列表?**如果它们的形参不同,就不会override了,而是隐藏了。
  4. **名字查找和继承。**当使用对象,指针或者引用调用某个函数时,可以分为四个步骤:
    首先确定静态类型。
    然后在这个静态类型中查找成员,如果没有找到,依次查找直接基类和间接基类,找到为止,如果一直到最后都没有找到,报错。
    找到了的话,就进行类型检验。名字查找优先于类型检查。
    如果调用合法,编译器根据调用的是否是虚函数产生不同的代码。如果是通过引用或者指针调用的虚函数,进行动态绑定;如果不是虚函数或者通过对象调用的函数,编译器产生一个常规函数调用。
  5. 派生类可以override重载的函数,如果派生类希望所有重载版本对它来说都是可见的,需要override所有的版本,或者一个也不override。可以使用using声明指定一个名字而不指定形参列表。这时,只需要定义派生类特有的函数就可以了。

构造函数和拷贝控制

虚析构函数

当存在继承关系时,需要在基类中将析构函数定义成虚函数。如果基类的析构函数不是虚函数,那么delete一个指向派生类对象的基类指针将会产生未定义的行为。
虚析构函数会阻止合成移动操作,即使是默认版本的虚析构函数。

合成拷贝控制和继承

  1. 合成的构造函数,赋值运算符和析构函数分别负责使用直接基类中对应的操作对一个对象中的直接基类部分进行初始化,赋值和销毁的操作。
  2. 删除的拷贝控制函数和基类的关系。
  3. 移动操作和基类的关系。大多数基类都会定义一个虚析构函数,虚析构函数是拷贝控制函数中的一个,所以基类通常不会含有合成的移动操作,而在在它的派生类中也没有合成的移动操作。

派生类的拷贝控制成员

  1. 派生类的拷贝或移动构造函数,需要调用基类的拷贝或者移动构造函数进行派生类的基类部分对象的拷贝或者移动。
  2. 派生类的赋值运算符,在派生类的赋值运算符中还要调用基类的赋值运算符。
  3. 派生类析构函数。析构函数中只需要进行派生类的析构就行了,基类的析构是自动执行的。
  4. 在构造函数和析构函数中调用虚函数时,分别调用与构造函数或者析构函数所属类型相对应的虚函数版本。因为派生类对象构造函数被调用时,首先调用基类的构造函数,这个时候派生类的对象还是未初始化的,所以就无法调用派生类的构造函数。而在析构过程中,同理。

继承的构造函数

  1. 派生类可以重用它的直接基类的构造函数,通过使用一条using声明:
    using Base::Base;
    就可以在派生类中使用基类的构造函数,并且在派生类中构造函数的访问级别和基类中相同。
  2. 当基类的构造函数含有默认实参的时候,派生类会获得多个继承的构造函数。
  3. 除了两个例外情况,派生类会继承所有基类的构造函数。一个是派生类中定义了和基类中参数列表相同的构造函数,另一个是基类的默认,拷贝和移动构造函数不会被继承。继承的构造函数也不会当做用户定义的构造函数使用,所以如果一个类如果只有一个继承的构造函数,它也会有一个合成的默认构造函数。

容器和继承

使用容器存放继承体系中的对象时,在容器中放置(智能)指针而非对象,这些指针的动态类型可能是基类类型,也可能是派生类类型。

参考文献

1.《C++ Primer》第五版

C++ operator overload

发表于 2019-11-10 | 更新于 2020-02-03 | 分类于 C/C++

什么是运算符重载

  1. 运算符重载有两种实现形式,一种是成员函数,一种是非成员函数的。如果一个运算符函数是成员函数,它的显式参数数量要比运算对象的数量少一个,第一个运算对象绑定到了隐含的this指针。
  2. 一个运算符函数,或者是类的成员,或者至少还有一个类型成员的函数。
  3. 一般不重载逗号运算符和取地址运算符,逻辑与和逻辑或运算符。
  4. 重载的运算符的求值顺序不确定,但是优先级,结合性以及操作数的数目都不变。
  5. 对于输入和输出运算符<<,>>,只能写成非成员函数,因为它们是作用在iostream上的,而不是作用在我们自己的对象上。
  6. 重载的运算符的含义应该和内置类型保持一致。
  7. [],=, ()和->必须是成员函数,否则就会编译出错。

输出和输出运算符

输出运算符

输出运算符的第一个形参是一个非常量ostream对象的引用:向流写入内容会改变其状态,所以形参ostream是非常量,因为无法复制ostream对象所以形参ostream是引用。
第二个形参一般来说是一个常量的引用:引用避免复制实参,常量意味着输出操作不会改变对象的内容。
输出运算符尽量减少格式化操作。

输入运算符

输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入的非常量对象的引用。

算术和关系运算符

可以把算术运算符和关系运算符定义成非成员函数,从而实现对左侧或者右侧对象的转换。

相等运算符

  1. 比较对象的每一个数据成员,只有对应的成员都相等时,才认为两个对象相等。
  2. 一般定义了==操作,也应该定义!=操作。但是,实际上只有一个操作负责实际比较的操作,另一个运算符工作委托给他。

关系运算符

operator==的结果必须和opeartor<的结果一致,即==为真的话,<为假;<为真,==就为假。

赋值运算符

  1. 赋值运算符必须定义为成员函数。
  2. 复合赋值运算符不一定必须定义为类的成员。
  3. 普通赋值和复合赋值都返回左侧运算对象的引用。

下标运算符

  1. 下标运算符必须是成员函数。
  2. 下标运算符通常需要定义两个版本,一个返回普通引用,另一个是类的常量成员,返回常量引用。

递增和递减运算符

  1. 自增自减运算符没有要求是成员函数,但是建议将其设定成成员函数。
  2. 前置版本返回递增或者递减后对象的引用。
  3. 重载无法区分后置和前置,后置版本接收一个额外的不被使用的int形参,进行区分。后置版本返回对象的原值,返回的是值而不是引用。

成员访问运算符

  1. 箭头运算必须是成员函数,而解引用不必。

函数调用运算符

  1. 函数调用运算符必须是成员函数。
  2. 一个类可以定义多个不同版本的调用运算符。
  3. 定义了函数调用运算符的类对象被称为函数对象,因为可以调用这种对象。

lambda是函数对象

标准库定义的函数对象

标准库定义了算术,关系,逻辑类型的函数对象,它们都是模板,定义在functional头文件中:
function_object

可调用对象和function

C++ 中的可调用对象:

  1. 函数
  2. 函数指针
  3. lambda表达式
  4. bind创建的对象
  5. 重载了函数调用运算符的类

可调用对象也有类型。比如每个lambda都有自己唯一的类型,函数和函数指针的类型由返回值类型和实参类型决定。
但是不同的可调用对象类型可能共享同一种调用形式,一种调用形式对应一个函数类型。

重载,类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换。

类型转换运算符

  1. 类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型,它的形式如下:
    1
    operator type() const;

这里的type是int, double等。

  1. 类型转换运算符没有显式的返回类型,没有形参,必须声明为类的成员函数,而且不应该改变待转换对象的的内容,因此一般定义为const成员。
  2. 实践中很少定义类型转换运算符,因为用户会感觉很意外。
  3. 通过加上explict关键字,必须使用static_cast显式调用才会进行类型转换。但是,当表达式用作条件时,显式的类型转换会被隐式的执行。

避免类型转换运算符产生的二义性

参考文献

1.《C++ Primer》第五版

C++ copy control

发表于 2019-11-10 | 更新于 2020-02-06 | 分类于 C/C++

概述

除了构造函数外,一个类通过定义五种特殊的成员函数:

  1. 拷贝构造函数
  2. 拷贝赋值运算符
  3. 移动构造函数
  4. 移动赋值运算符
  5. 析构函数

控制类对象的拷贝,移动,赋值和销毁时执行的各种动作,这些操作称为拷贝控制操作(copy control)。

拷贝,赋值和销毁

在含有指针数据成员的类中,通常需要定义拷贝构造函数,拷贝赋值运算符和析构函数。下面介绍一些它们的主要特点,学习它们通过例子更容易。

拷贝构造函数

  1. 拷贝构造函数通常不应该是explicit的。
  2. 在含有指针数据成员的类中,默认的拷贝构造函数会失效。
  3. 拷贝初始化和直接初始化的区别。使用=号初始化是拷贝初始化,不使用=号就是直接初始化。直接初始化时,实际上是根据传入的参数调用相应的构造函数,是函数匹配。而拷贝初始化是使用拷贝构造函数将右侧的对象拷贝到正在创建的对象那个。
  4. 什么时候会用到拷贝构造函数:
    • 使用=初始化对象时;
    • 将一个对象作为实参传递给非引用的形参时
    • 将一个对象作为实参传递给一个非引用类型时;
    • 用花括号从列表初始化一个数组中元素或者一个聚合类的成员时;
    • 某些标准容器调用insert或者push成员时。

拷贝赋值函数

  1. 在含有指针数据成员的类中,默认的赋值运算=只能将指针指向被拷贝元素的数据。
  2. 赋值运算符通常应该返回一个指向其左侧运算对象的引用。

析构函数

  1. 释放对象使用的资源,销毁对象的非static数据成员。
  2. 析构函数没有返回值,也不接受重载。

=default

=default将函数定义为合成的。只能对默认构造函数或者拷贝控制函数使用=default,=default可以出现在类外定义成员函数时。

阻止拷贝

有时候某些拷贝控制函数不需要定义,比如iostream类不能拷贝,以避免多个对象写入或者读取相同的IO缓冲。可以通过将拷贝构造函数和拷贝赋值函数定义为删除的函数阻止拷贝。
对于删除的函数:我们虽然声明了它们,但是不能以任何方式使用它们。在函数的参数列表之后加上=delete将函数定义为删除的,这会通知编译器,我们不希望定义这些成员。=delete必须出现在函数第一次声明的时候,而且除了析构函数之外的任何函数都可以指定=delete。对于析构函数来说,如果声明为=delete的,那么这个对象就不能被释放了。而对于除了拷贝控制和默认构造函数之外的其他函数,定义=delete就是多次一举了,如果你不需要,直接不定义就是了,但是实际上这种做法是合法的。
**合成的拷贝控制成员可能是删除的。**如果一个类有一个数据成员不能默认构造,拷贝,复制或者销毁,则对应的成员函数被定义为删除的。比如:

  1. 类的某个成员的析构函数 是删除的或不可访问的,类的合成析构函数被定义为删除的。
  2. 类的某个成员的拷贝构造函数 是删除的或不可访问的,类的合成拷贝构造函数也被定义为删除的。
  3. 类的某个成员的拷贝赋值运算符是删除的或不可访问的,类的合成拷贝赋值运算符被定义为删除的。
  4. 类的某个成员的析构函数是删除的或者不可访问的,类的合成析构函数,合成默认构造函数,合成拷贝构造函数也被定义为删除的。
  5. 类的某个成员是没有类内初始化器的引用,或者有一个没有类内初始化器的const成员,类的默认构造函数和合成拷贝赋值运算符被定义为删除的。

拷贝控制和资源管理

通过定义拷贝操作,可以使得类的行为看起来像一个值或者像一个指针。
类的行为像值,就是说拷贝一个像值的对象时,副本和原来的对象是完全独立的,改变副本不会对原来的对象有任何影响,反之亦然。
类的行为像指针,就是说拷贝一个向指针的对象时,副本和原来的对象使用相同的底层数据,改变副本也会影响原来的对象,反之亦然。

行为像值的类

  1. 定义拷贝构造函数,完成对象内容的拷贝,而不是指针的拷贝。
  2. 定义拷贝赋值函数,需要考虑自赋值的情况。一般来说,拷贝赋值运算符组合了析构函数和拷贝构造函数的工作。
  3. 定义析构函数,释放对象占用的空间。

行为像指针的类

通过引用计数来实现。

swap函数

这个的目的就是交换指针而不是交换对象内容。

对象移动

移动而不是拷贝对象,可能会大幅度提高性能。比如vector在扩容的时候,将元素从旧内存拷贝到新内存是不必要的;另一方面,IO类或者unique_ptr等类,包含不能共享的资源,这些资源不能拷贝但是可以移动。
标准库容器,string和shared_ptr,即支持移动也支持拷贝。IO类和unique_ptr类可以移动但是不能拷贝。

右值引用

  1. 右值引用只能绑定到临时对象,所以它有两个属性:它所引用的对象将要被销毁,并且该对象没有其他用户。
  2. 左值引用不能绑定到要求转换的表达式,字面常量或者是返回右值的表达式。而右值引用可以绑定到上述三类表达式,但是不能将一个右值绑定到一个左值上。
    返回左值表达式的例子:返回左值引用的函数,赋值,下标,解引用和前置递增递减运算符。可以将一个左值引用绑定到这类表达式的结果上。
    返回右值表达式的例子:返回非引用类型的函数,算符,关系,位和后置递增递减运算符。不能将左值引用绑定到这类表达式上,但是可以将一个const的左值引用或者右值引用绑定到这类表达式上。
  3. 变量是左值。变量可以看做只有一个运算对象而没有运算符的表达式。变量表达式都是左值。
  4. move函数。不能将一个右值引用直接绑定到左值上,但是可以使用utility头文件中的move函数将一个左值转换成对应的右值引用类型。

移动构造函数和移动赋值函数

  1. 不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。
    移动操作通常不抛出异常,但是抛出异常也是允许的;当异常发生时,标准库容器能够为自身的行为提供保障。比如,vector保证,调用push_back发生异常时,vector自身不会改变。所以,在调用push_back需要重新分配内存时,使用拷贝构造函数抛出异常时,会保证原来的vector不受影响,但是如果调用能够抛出异常的移动构造函数,在调用失败的时候,原来的vector会受到影响,不能保证vector自身不变。
    所以,只有将移动构造函数声明为noexcept时,明确说明调用移动构造函数不会抛出异常,在vector扩容重新进行内存分配的时候,才会使用拷贝构造函数。
  2. 合成的移动操作。如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。当一个类没有定义任何自己版本的拷贝控制函数,并且类的每个非static数据成员都可以移动时,才会合成移动构造函数或者移动赋值函数。
  3. 如果类定义了一个移动构造函数或者一个移动赋值运算符,类的合成拷贝构造函数和合成拷贝赋值运算符会被定义成删除的。

成员函数和右值引用

成员函数的参数可以是左值或右值

除了构造函数和赋值运算符之外,一个成员函数也可以同时提供拷贝和移动版本:一个版本接收一个指向const的左值引用,一个版本接收一个指向非const的右值引用。

左值或者右值调用成员函数

  1. C++ 可以对右值进行赋值。C++ 11中可以阻止这种用法,通过引用限定符强制左侧运算对象是一个左值。
  2. 在参数列表后放置一个引用修饰符,&或者&&,分别指出this可以指向一个左值或者右值。
  3. 类似const限定符,引用限定符只能用于非static的成员函数,并且需要同时出现在声明和定义中。
  4. 对于&修饰的函数,只能将它用于左值,对于&&修饰的函数,只能将它用于右值。
  5. 一个函数既可以用const也可以用引用限定,当它们同时出现时,引用限定符必须跟随在const限定符之后。
  6. 引用限定符可以区分重载函数。当出现两个或者两个以上具有相同名字或者参数列表的函数时,必须同时有引用修饰符或者同时没有。

参考文献

1.《C++ Primer》第五版

C++ dyncamic memory

发表于 2019-11-10 | 更新于 2020-02-21 | 分类于 C/C++

概述

每一个C程序都把内存划分成静态内存,栈内存,堆内存(自由空间)。静态内存存放局部static对象,类的static数据成员以及定义在任何函数之外的变量。栈存放函数内的非static对象。堆内存是由程序员自己负责管理(申请和释放)的内存。

动态内存和智能指针

C++ 中动态内存的管理是通过一对运算符new和delete实现的。new在动态内存中为对象分配空间,并且返回一个指向该对象的指针,可以选择对对象进行初始化;delete接收一个动态对象的指针,销毁该对象,释放和它相关的内存。

动态内存很难管理,有时候忘记释放内存,会产生内存泄露;有时候在有指针引用内存的情况下就释放了它,这种情况下产生非法引用的内存。
C++ 提供了两种智能指针,shared_ptr和unique_ptr管理动态对象。智能指针也是模板。因此,在创建智能指针的时候,需要提供类型信息。
shared_ptr允许多个指针指向一个对象,而unique_ptr则独占所指向的对象。
下面是shared_ptr和unique_ptr都支持的一些操作:
smart_pointer

shared_ptr

shared_ptr

  1. shared_ptr的声明和创建
  2. make_shared创建一个指针。
  3. shared_ptr的拷贝和赋值,引用计数。修改引用计数的几种情况:
    • 拷贝一个shared_ptr,比如用一个shared_ptr初始化另一个,值传参,返回值等情况,引用计数增加。
    • 给shared_ptr赋一个新值,引用计数减少。
    • shared_ptr被销毁时,引用计数减少。
  4. 通过析构函数自动销毁它管理的对象。

new

初始化

  1. 默认初始化。new后面加类型,没有小括号,也没有花括号。
    默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的。这意味着内置类型或组合类型的对象的值是无定义的,而类类型对象将用默认构造函数进行初始化。
  2. 值初始化。类型名字后加()即可,对于内置类型的变量,初始化为0,对于类类型的变量,调用默认构造函数。
  3. 直接初始化。使用初始化列表加对象值,或者小括号加对象值。

对于自定义类型而言,只要一调用new,无论后面有没有加(),那么编译器不仅仅给它分配内存,还调用它的默认构造函数初始化。

还有auto初始化器。

内存耗尽

使用placement new(定位new),当内存耗尽时,防止new抛出bad_alloc异常。

shared_ptr和new

  1. 定义和改变shared_ptr的其他方法,shared_ptr对象还有其他几个构造函数,分别接收内置指针,unique_ptr,以及内置指针和内置指针的删除器,shared_ptr对象和它的删除器这几类参数。接收内置指针参数的shared_ptr的构造函数是explict的,也就是必须显式调用构造函数,不能使用隐式转换将一个内置指针转换成shared_ptr。
  2. 当把一个shared_ptr指针绑定到一个普通指针时,接下来不应该再使用内置指针访问这部分内存了。
  3. 也不要使用shared_ptr的get()函数返回的指针初始化另一个智能指针或者为智能指针赋值。同时也不能使用delete删除get()返回的指针,否则会发生二次delete。
  4. p.reset()函数可以用来重置指针p,如果p是指向shared_ptr的唯一对象,会将p原本指向的对象释放;如果没有传入参数,p置为空;如果传入了参数q,让p指向q;还可以传递一个d,表示调用d而不是delete释放q。
  5. copy on write可以通过shared_ptr实现???使用unique()函数检测是否自己是当前的shared_ptr的唯一用户,不是的话,调用reset()函数拷贝一个新的。

智能指针和异常

智能指针和哑类

使用自己的释放操作

shared_ptr假设它们指向的内容是动态内存,当它被销毁时,调用delete。我们可以自己定义一个删除器,取代delete的调用。

1
2
3
4
5
6
7
8
9
10
void end_connection(connection *p)
{
disconnect(*p);
}
void func()
{
connection c = connect(&d);
shared_ptr<connection> p(&c, end_connection);
// 创建一个connection类型的智能指针,构造函数的参数是一个内置的指针类型和一个调用函数。
}

unique_ptr

unique_ptr

weak_ptr

weak_ptr不控制指向对象的生存周期,它指向一个shared_ptr管理的对象,将一个weak_ptr绑定到shared_ptr不增加引用计数。
weak_ptr

new和数组

初始化

  1. new int[],默认初始化。
  2. new int[](),值初始化。但是不能在括号中给出初始化器,也就是不能用auto分配数组。
  3. new int[10]{1, 2, 3},列表初始化。

动态分配空数组

动态分配一个空数组是允许的,但是不能解引用。
而定义一个长度为0的数组是不允许的。

智能指针和动态数组

unique_ptr有一个可以管理new分配的数组的版本。而shared_ptr没有相应的版本,如果想要使用shared_ptr管理数组,需要提供自己定义的删除器。

allocate类

参考文献

1.《C++ Primer》第五版

C++ STL associative container

发表于 2019-11-10 | 更新于 2020-01-10 | 分类于 C/C++

C++标准程序库中的关联容器

有序关联容器

map, multimap, set, multiset的底层实现是红黑树。

无序关联容器

unordered_set, unordered_multiset, unordered_map, unordered_multimap的底层实现哈希。

pair

pair是一种标准库类型,定义在头文件utility中,一个pair保存两个数据成员,是一个生成特定类型的模板。创建一个pair时,需要提供两个类型名(可以相同,也可以不同)。
pair的数据成员是public的,两个成员的名字分别为first和second。pair支持的操作如下表。
pair

关联容器的定义

关联容器的类型

除了所有容器都有的类型之外,关联容器还有一些特有的类型:

  • key_type,关键字的类型。
  • mapped_type,每个关键字关联的类型,只有map有。
  • value_type,对于set,和key_type相同,对于map,和pair<key_type, mapped_type>相同。

添加元素(增)

可以使用以下几个函数进行插入操作:

  • c.insert(v)
  • c.emplace(args)
  • c.insert(b, e)
  • c.insert(il)
  • c.insert(p, v)
  • c.emplace(p, args)

对map和set进行insert时,insert的返回值是一个pair,pair的第一个元素是迭代器,第二个元素是个bool类型,之处是否插入成功。
而对multimap和multiset进行insert时,insert不需要返回bool值,因为插入总是成功的。

删除元素(删)

  • c.erase(p),删除迭代器p指定的元素。
  • c.erase(k),删除key为k的元素,返回值为删除的元素的数量。
  • c.erase(b, e)

下标操作

  • c[k],返回关键字为k的元素。如果k不在c中,添加一个关键字为k的元素,进行值初始化。
  • c.at(k),访问关键字为k的元素,进行参数检查,如果k不在c中,抛出一个out_of_range异常。

查找操作(查)

  • c.find(k),
  • c.count(k),
  • c.lower_bound(k),不适用于无序容器,
  • c.upper_bound(k),不适用于无序容器,
  • c.equal_range(k)

关联容器的无序版本

bucket的管理

参考文献

1.《C++ Primer》第五版

1…91011…34
马晓鑫爱马荟荟

马晓鑫爱马荟荟

记录硕士三年自己的积累

337 日志
26 分类
77 标签
RSS
GitHub E-Mail
© 2022 马晓鑫爱马荟荟
由 Hexo 强力驱动 v3.8.0
|
主题 – NexT.Pisces v6.6.0