学习笔记 | C++ 学习笔记

早年学习 C++ 时的完整笔记,涵盖 Linux 基础、C++ 语法、面向对象、STL 等内容。

目录

Linux历史与基本命令

Linux操作系统发展历史

  1. 1965 麻省理工学院开发multics;

  2. 1969 Ken Thompson, Dennis Ritchie和Douglas Mcllroy发明早期Unix系统,且1972年用C语言重写内核;

    Ken Thompson, Dennis Ritchie为 Unix之父

    Dennis Ritchie为 C语言之父

  3. 1979 Unix开始收费;

  4. 1983 Richard Stallman发起GUN计划;

  5. 1984 Andrew Tanenbaum教授计划写一个不含AT&T源代码的Unix操作系统,并且在1987年实现,取名为Minix;

    Minix为微内核

  6. 1991 Linus Torvalds开发Linux内核;

    Linux为宏内核

Linux基本设置

  1. 开启终端 ctrl+alt+T
  2. 目录 树状网络,以根目录/为根
  3. 使用Xshell连接Ubuntu
  4. 固定IP 保证每次连接同一IP。

Linux基本命令

  1. 用户创建简略版 useradd <username>

    此时创建的用户没有家目录、密码和shell。

  2. 执行cat命令查看/etc/passwd(节选) root:x:0:0:root:/root:/bin/bash lizi:x:1000:1000:lizi,,,:/home/lizi:/bin/bash sshd:x:122:65534::/run/sshd:/usr/sbin/nologin

    用户名:密码:UID:GID:(一些注释):用户所在目录:shell的类型

  3. 用户创建

    useradd -m <username> -s /bin/bash

  4. 设置密码 passwd <username>

  5. cd命令 改变工作目录。

    切换到上一级目录 cd .. 切换到当前目录 cd . 切换到上一次的目录 cd -

  6. pwd命令

    查看当前工作目录。

  7. ls命令

    查看当前工作目录下的文件。

  8. history命令

    可以将之前的命令输出。

Linux基本命令

Linux基本命令

Linux命令搜索引擎 命令 - Linux 命令搜索引擎 (wangchujiang.com)

  1. 清屏

    ctrl+Lclear

  2. 切换用户

    su <userid>

  3. 删除用户

    userdel <userid>

    只会在/etc/passwd中删除用户,不会删除与用户相关的文件。

  • -r 可以同时删除与该用户相关的文件。
  1. 创建文件夹

    mkdir <dirname>

  • -p创建级联目录
  1. 删除空文件夹

    rmdir <empty_dirname>

    必须要是空文件夹。

  2. 删除文件夹

    rm <dirname>

  • -fr 同时删除子目录

文件的创建

  1. touch命令

    改变文件的时间戳。

     touch 1.txt
    
  2. cat命令

    cat - concatenate files and print on the standard output

  3. echo命令

    echo - display a line of text

    echo "123" > 1.txt //将123写入1.txt中,如果文件不存在时会创建文件。
    echo -n > file1 //创建的文件属于空文件
    echo "" > file2 //此时创建的文件里面有一个换行
    

    cat和echo创建文件的时候利用了重定向的概念。

    重定向:重新指定方向。

  4. vi/vim命令

    vim - Vi IMproved, a programmer’s text editor

    vim 2.txt
    //在文件里按:wq退出。
    

文件的查看

  1. cat命令

    cat命令不适用大文件。

  2. head命令

    head命令会显示文件的前面一部分内容。

    如果希望显示前m行,可以使用如下命令:

    head -n m
    
  3. tail命令

    tail命令会显示文件的后面一部分内容。

    如果希望显示后m行,可以使用如下命令:

    tail -n m
    
  4. vim命令

    最常用的命令。用于替代上面的命令。

文件的移动、复制与删除

  1. cp命令

    复制文件或目录。

    cp file1 file2 //Copy SOURCE to DEST
    cp file1 dir1 //Copy multiple SOURCE(s) to DIRECTORY.
    cp -r dir1 dir2 //可以将dir1中的文件或目录都复制到dir2下,其中-r表示递归
    cp -i file1 dir1 //此时如果目标目录中有同名文件会先询问是否要覆盖,给予提醒
    
  2. tree命令

    tree - list contents of directories in a tree-like format.

  3. mv命令

    mv - move (rename) files

    mv file1 file2 //将file1改名为file2
    mv file1 dir1 //将file1放进dir1中
    mv dir1 dir2 //将dir1放进dir2中
    

    cp命令需要递归但是mv命令不需要。可以将mv命令理解为指针的切换。

    建议在刚开始的时候都带上-i

  4. rm命令

    rm - remove files or directories

    rm -r dir //删除目标文件夹
    rm -ir dir //会加上提示
    rm -f dir //强制删除
    
  5. ls命令

    ls - list directory contents

    ls -a //显示隐藏文件(.filename) 
    ls -i //print the index number of each file
    ls -h //with -l and/or -s, print human
    readable sizes (e.g., 1K 234M 2G)
    
    ls -l //use a long listing format
    

硬链接与软连接

ln - make links between files

  1. 软连接
ln -s oldFile newFile

软链接相当于创建的是快捷方式。软连接文件与源文件的索引结点号是不一样,文件的类型也是不一样的。文件的大小也是不一样,因为软链接文件的大小就是源文件的路径加上文件名字的长度。但是查看源文件的内容与链接文件的内容是一样。如果源文件删除了,那么快捷方式就会失效。

  1. 硬链接
ln oldFile  newfILE   //创建的硬链接,两个文件的内容是一样的,索引结点号也是一样;修改任何一个文件的内容,另外一个文件也会修改;删除掉一个文件,会使硬链接数减少1个;

Snipaste_2024-01-10_20-44-27

总结:

1、两者的创建方式都是使用命令ln,但是软链接的时候需要加上-s选项

2、硬链接相当于创建了文件的副本,但是软链接相当于创建的是快捷方式

3、修改源文件的时候,查看新文件的内容的时候,都会发生改变。

4、删除源文件的时候,对于硬链接而言,文件内容是不会受到任何影响,但是链接数会减1;对于软链接而言,如果源文件删除了,那么软链接就失效了。

5、硬链接文件的索引结点号是一样的,但是软链接索引结点号是不一样;

查找、vimplus与编译过程

权限修改

  1. chmod命令

    改变文件的权限。

    -rw-rw-r--  1 lizi lizi    5 1月   9 16:07 2.txt //权限有r读w写x执行-不设置,有三组,分别是u-本用户,g-组用户,o-其他,a或不写-所有用户。                         
    
    chmod u+w,g-x,o=r file1 //本用户多一个w权限,组用户少一个x权限,其他用户只有r权限。 
    chmod 741 2.txt //r对应4,w对应2,x对应1,此时741表示本用户的权限为7,即rwx,4表示组用户的权限为4,即r--,1表示其他用户的权限为1,即--x。
    

文件查找

  1. find命令

    按照文件名字/文件内容/文件大小等进行查找。

    find -name "<filename>" //按照文件名字查找
    find /home/lizi -name "<filename>" //在/home/lizi目	录下按照文件名字查找
    
  2. 文件通配符

    * 表示0-任意个字符

    ? 表示0/1个字符

    []表示范围内的任一字符

  3. 查找条件的运算

    查找条件可以进行逻辑运算,比如与或非。

    1. -a表示逻辑与

      find /home/lizi -name 0718 -a -type d //在/home/lizi下查找类型为目录、名字为0718的文件。
      
    2. -o表示逻辑或

      find /home/lizi -name main.cc -o -name main.c //在/home/lizi目录下查找名字为main.cc或名字为main.c的文件
      
    3. !表示逻辑非

      find /home/lizi ! –name main.c //在/home/lizi下查找名字不是main.c的文件
      
  4. grep命令

    依照文件内容进行查找。

    -F 每个模式作为固定的字符串对待
    -E 使用扩展正则表达式
    -c 只显示匹配行的数量
    -i 比较式不区分大小写
    -n 在输出前加上匹配串所在的行号
    -r 用于在目录中进行搜索(递归),相当于-d recurse
    

    支持正则表达式。

    a|b 匹配 a 或 b 使用-E
    gr(a|e)y 匹配 gray 或 grey
    . 匹配任一字符
    [abc] 匹配任一字符:a或b或c
    [^abc] 匹配任一字符,但不包括a,b,c
    [a-z] 匹配从 a 到 z 之间的任一字符
    [a-zA-Z] 匹配从a到z,及从A到Z之间的任一字符
    ^表达式 匹配字符串的头部 使用-E
    表达式$ 匹配字符串的尾部 使用-E
    ( ) 匹配标记的子表达式 使用-E
    \b 匹配字词边界
    \n 匹配第n个标记的子表达式,n代表1到9
    * 匹配前一项内容0或多次
    ? 匹配前一项内容0或1次 使用-E
    + 匹配前一项内容1或多次 使用-E
    {x} 匹配前一项内容x次
    {x,} 匹配前一项内容最少x次
    {,x} 匹配前一项内容最多x次
    {x,y} 匹配前一项内容次数介于x和y之间 使用-E
    \ 转义字符
    

    例子:

    grep -n ^int main.c //在main.c中找到以int开始的语句
    grep -n \;$ main.c //在main.c中找到以;结尾的语句
    grep -nE "ee(ee)*" file4 //在file4中寻找至少含有两次e的语句
    grep -nE "eeeee(eee)" file4 //在file4中寻找含有5+3n次e的语句
    grep -nE "eeeee*" file4 //在file4中寻找至少含有5次e的语句
    

重定向

  • 标准输入重定向:<, 0<, stdin

  • 标准输出重定向:>, 1>, stdout

  • 标准错误输出重定向:2>. stderr

    0,1,2在LInux中称为文件描述符

    Snipaste_2024-01-10_09-49-49

  • 追加模式

    1. 标准输出的追加:>>
    2. 标准错误输出的追加:2>>
  • cat命令

    cat - concatenate files and print on the standard output

    //读取文件的两种方式
    cat file2
    cat < file2
    
    //此时<<不是追加模式,而是将file2作为终止符
    cat << file2
    

vim的基本操作

  1. 安装

    sudo apt install vim
    
  2. 基本操作

    1. 普通/命令模式

      x 删除光标处的字符
      dd 删除光标所在的整行
      3dd 删除光标所在行以及下面的两行(删除3行)
      D或d$ 删除光标到行尾的文本,常用语删除注释语句 (等价于d$)
      d^ 删除光标到行首的文本
      dw 删除一个字
      d3w 删除三个字
      yy 复制光标所在的整行
      [n]yy 从光标开始往下复制n行,[n]表示一个整数
      p 将复制后的文本粘贴到光标处
      u 撤销上次操作
      y[n]G 从光标开始复制到n行
      d[n]G 从光标开始删除到n行
      
    2. 编辑模式

      i a o

    3. 视觉模式

      v或者ctrl+v

  3. vimtutor

    vim的说明书,使用此命令来调出说明进行学习。

vimplus

  1. 安装

  2. 配置

    /home/lizi/.vim/plugged/prepare-code/snippet下进行操作,可以修改默认函数。

gcc

#include <stdio.h>
#define  NUM 3 + 4

//argc参数的个数
//argv将命令行参数记录在下来
int main(int argc, char *argv[])
{
	printf("num * num = %d\n", NUM  * NUM);   
	return 0;
}

源代码到目标文件的步骤

  1. 预处理

    主要处理源代码中以#开头的预处理指令。

    gcc -E hello.c -o hello.i
    //-E只进行预处理
    
    file hello.c
    hello.c: C source, UTF-8 Unicode text
    
    file hello.i
    hello.i: C source, ASCII text
    

    在此时,会将宏定义:在预处理阶段做字符串的替换

    因此,此时上面的文件会生成

    ...
    int main(int argc, char *argv[])
    {
    	printf("num * num = %d\n", 3 + 4  * 3 + 4);   
    	return 0;
    }
    
  2. 编译

    gcc -S hello.c -o hello.s
    //-S进行到编译
    

    进行语法检测、词法检测、语义检测。

    file hello.s
    hello.s: assembler source, ASCII text
    

    此时生成汇编代码。

       .file   "hello.c"
       .text                                           
       .section    .rodata
   .LC0:
       .string "num * num = %d\n"
       .text
       .globl  main
       .type   main, @function
   main:
   .LFB0:
       .cfi_startproc
       pushq   %rbp
       .cfi_def_cfa_offset 16
       .cfi_offset 6, -16 
       movq    %rsp, %rbp
       .cfi_def_cfa_register 6
       subq    $16, %rsp
       movl    %edi, -4(%rbp)
       movq    %rsi, -16(%rbp)
       movl    $19, %esi
       leaq    .LC0(%rip), %rdi
       movl    $0, %eax
       call    printf@PLT
       movl    $0, %eax
       leave
       .cfi_def_cfa 7, 8
       ret 
       .cfi_endproc
   .LFE0:
       .size   main, .-main
       .ident  "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.
       .section    .note.GNU-stack,"",@progbits
   
  1. 汇编

    将汇编代码翻译成机器可以执行的指令(机器码文件)。

    as hello.s -o hello.o
    

    此时为二进制文件。

    file hello.o
    hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
    

    可以nm命令来查看hello.o文件

    nm hello.o
                     U _GLOBAL_OFFSET_TABLE_
    0000000000000000 T main
                     U printf
    
  2. 链接

    gcc hello.o -o hello
    

    生成可执行二进制文件hello

    file hello
    hello: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=1dd0d0b83101d323bd6fd026e3c7284157c58f2c, not stripped
    
    nm hello
    0000000000201010 B __bss_start
    0000000000201010 b completed.7698
                     w __cxa_finalize@@GLIBC_2.2.5
    0000000000201000 D __data_start
    0000000000201000 W data_start
    0000000000000570 t deregister_tm_clones
    0000000000000600 t __do_global_dtors_aux
    0000000000200dc0 t __do_global_dtors_aux_fini_array_entry
    0000000000201008 D __dso_handle
    0000000000200dc8 d _DYNAMIC
    0000000000201010 D _edata
    0000000000201018 B _end
    00000000000006f4 T _fini
    0000000000000640 t frame_dummy
    0000000000200db8 t __frame_dummy_init_array_entry
    0000000000000854 r __FRAME_END__
    0000000000200fb8 d _GLOBAL_OFFSET_TABLE_
                     w __gmon_start__
    0000000000000714 r __GNU_EH_FRAME_HDR
    00000000000004f0 T _init
    0000000000200dc0 t __init_array_end
    0000000000200db8 t __init_array_start
    0000000000000700 R _IO_stdin_used
                     w _ITM_deregisterTMCloneTable
                     w _ITM_registerTMCloneTable
    00000000000006f0 T __libc_csu_fini
    0000000000000680 T __libc_csu_init
                     U __libc_start_main@@GLIBC_2.2.5
    000000000000064a T main
                     U printf@@GLIBC_2.2.5
    00000000000005b0 t register_tm_clones
    0000000000000540 T _start
    0000000000201010 D __TMC_END__
    

直接生成

gcc hello.c -o hello

动态库与静态库

代码

  • add.c
//函数名字叫做add
//函数的返回类型是int
//函数的参数列表是int,int
//函数的形参是x,y
//大括号范围就是add函数的函数体
//大括号里面的代码是函数的实现
int add(int x, int y)  
{
    return x + y;
}     
  • main.c
#include <stdio.h>
  
//声明了函数add                                        
int add(int, int);
  
int main(int argc, char *argv[])
{
    int a = 10; 
    int b = 20; 

    printf("add(10, 20) = %d\n", add(a, b));
    return 0;
}

静态库与动态库

  1. 静态库
//1、编译源代码生成目标文件
gcc -c add.c -o add.o   //add可以自己随便取名xxx,-c选项是只编译不链接

//2、使用ar命令进行打包库
ar crsv libadd.a add.o   //静态库的文件名字叫做libadd.a,约定以lib开头,静态库以.a结尾

//3、将静态库移动到系统的目录中
sudo cp libadd.a /usr/lib

//4、进行链接静态库
gcc main.c -o main -ladd    //-ladd中的l是库的首字母,后面的add与之前的xxx保持一致即可

//测试
./main
接下来可以直接执行main可执行程序了

//注意:此时如果将/usr/lib下的libadd.a文件删除了,也没有影响,可执行程序main也是可以运行的
  1. 动态库
//1、编译源代码生成目标文件
gcc -c add.c -o add.o -fpic  //add可以自己随便取名xxx,-c选项是只编译不链接,-fpic与位置无关
    
//2、进行打包动态库
gcc -shared -o libadd.so add.o   //动态的文件名字叫做libadd.so,约定以lib开头,动态库以.so结尾
    
//3、将动态库移动到系统的目录中
sudo cp libadd.so /usr/lib
    
//4、进行链接动态库
gcc main.c -o main -ladd    //-ladd中的l是库的首字母,后面的add与之前的xxx保持一致即可
    
//测试
./main
接下来可以直接执行main可执行程序了
  
//注意:此时如果将/usr/lib下的libadd.so文件删除了,执行程序main是会报错

静态库是目标文件(libname.a)的归档文件。在编译程序时链接静态库,链接器会搜索静态库并直接拷贝到该程序的可执行二进制文件到当前文件中。

动态库(libname.so)在程序编译时不会链接,而是在程序运行时才被载入。

  • 动态库只在执行时才被链接使用,不是直接编译为可执行文件,并且一个动态库可以被多个程序使用,故可称为共享库。
  • 静态库将会整合到程序中,在程序执行时不用加载静态库。

因此,静态库会使你的程序臃肿并且难以升级,但比较容易部署。而动态库会使你的程序轻便易于升级但难以部署。

  1. ldd命令

    可以查看依赖。

    ldd main
    	linux-vdso.so.1 (0x00007ffed01b1000)
    	libadd.so => /usr/lib/libadd.so (0x00007f4a7aafe000)
    	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4a7a70d000)
    	/lib64/ld-linux-x86-64.so.2 (0x00007f4a7af02000)
    

C++命名空间、const关键字、new与delete以及引用

C++历史

  1. C语言

    • 1972年
    • 面向过程,按照事物的发展顺序进行。
  2. C++

    • 1983年
    • 由Bjarne开发
    • 面向对象,有基本特征:抽象、封装、继承、多态。

第一个程序

C++的源代码以.cc或者.cpp结尾,头文件以.h或者.hpp结尾。

  • C++代码示例:
#include <iostream>
  
using namespace std;

int main(int argc,char *argv[])
{
  cout << "Hello World!" << endl;
  return 0;
}
  • C语言代码示例:
#include <stdio.h>

int main(int argc, char *argv[])
{
    printf("Hello world\n");
    return 0;
}

Q1:C++的头文件为何没有将其写为.h

  • C++的结尾不是由.h结尾。C++的代码都是使用模板来写的,模板的特点是必须要知道所有的实现之后才能进行正常的编译。

Q2:函数声明与函数实现的区别

  • 函数的声明可以有多次,函数的实现只可以有一次。
  • 一次定义多次使用。
  • 函数的声明没有函数体,函数的实现有函数体。

Q3:注释的三种方式

 #if 0
 ...
 #endif
 //...
 /*...*/

快速注释:在vimplus可以使用n gcc

Q4:简单程序中包含的C++语法知识

#include <iostream>
  
using namespace std;

int main(int argc,char *argv[])
{
    cin >> number;
    cout << "number = " << number << endl;  //endl,end of line,即换行
    return 0;
}
  • 输入输出流对象cin cout

  • 输入输出流运算符>> <<

  • 运算符重载operator<<

    operator<<(cout, "Hello world");
    cout.operator<<(endl);
    
    operator<<(cout, "Hello world").operator<<(endl);
    

命名空间

目的

名字冲突就是在同一个作用域中有两个或多个同名的实体,为了解决命名冲突 ,C++中引入了命名空间,所谓命名空间就是一个可以由用户自己定义的作用域,在不同的作用域中可以定义相同名字的变量,互不干扰,系统能够区分它们。

语法

using namespace std;
namespace <namespace_name>{
...
}//end of namespace <namespace_name>

使用形式

  1. using编译指令

    using namespace std;
    //std是标准命名空间的名字
    
    • 设置全局命名空间

    • 优点

      可以一次性将标准命名空间std中的实体全部都引出来,方便快捷。

    • 缺点

      不完全知道std中有哪些实体,所以可能自己再去定义的时候实体名字会产生冲突。

  2. 作用域限定符

    int cout(int x, int y){
        std::cout << "x = " << x << y << std::endl;
    }
    
    • 优点

      即使自己定义的实体与命名空间std中的实体名字一样也没有问题

    • 缺点

      每一次使用std中的实体的时候都需要加上std::,比较麻烦

  3. using声明机制★

    using std::cout;
    using std::endl;
    
    • 一次只引出命名空间中的一个实体
    • 可以解决作用于限定符较为麻烦的缺点
    • 可以解决using编译指令的冲突问题

命名空间的扩展★

命名空间是可以进行扩展的。

自己定义的命名空间可以进行扩展,标准命名空间std也是可以进行扩展。

//标准命名空间也是可以进行扩展的                               
namespace std 
{
	struct MyStruct
    {
        int a;
        double b;
    };
}//end of namespace std

//重要:命名空间是可以进行扩展的
namespace  wd  
{
int	number = 100;
//带命名空间的函数声明                              
void print();
}//end of namespace wd

namespace  wh
{
void display()
{
    cout << "void wh::display()" << endl;
    //想在display中调用wd中的print
    wd::print();
}//end of namespace wh
    
namespace wd
{
int number2 = 200;
void print()
{
    cout << "void wd::print()" << endl;
    //想在print中调用wh中的func
    wh::func();
}//end of namespace wd
    
int main(void)
{
    using wh::display;
    display();
    return 0;
}

匿名命名空间

由于没有名字,该空间中的实体,其它文件无法引用,它只能在本文件的作用域内有效,它的作用域是从匿名命名空间声明开始到本文件结束。在本文件使用无名命名空间成员时不必用命名空间限定。

其实匿名命名空间和static是同样的道理,都是只在本文件内有效,无法被其它文件引用。

namespace {
    int val1 = 10;
    void func();
}//end of anonymous namespace

形式参数、命名空间与全局变量

#include <stdio.h>
#include <iostream>

using std::cout;
using std::endl;

int number = 1; //全局变量number

namespace wd
{

int number = 20; 

void show(int number)
{
    cout << "形参number = " << number << endl; 
    cout << "命名空间中number = " << wd::number << endl; 
    cout << "全局变量number = " << ::number << endl;
    printf("hello,world\n");
    ::printf("hello,world\n");
}

}//end of namespace wd

int main(int argc, char *argv[])
{
    int value = 300;
    wd::show(value);

    return 0;
}

输出结果为:

形参number = 300
命名空间中number = 20
全局变量number = 1
hello,world
hello,world
  • C++兼容C,C中的函数(如printf)都认为在匿名命名空间中。

命名空间的嵌套

int number = 1;
namespace wd
{
int number = 10;
namespace wh
{
int number = 100;
void display()
{
	cout << "wd::wh::display()" << endl;
}
}//end of namespace wh
void display(int number)
{
    cout << "形参number = " << number << endl;
    cout << "wd命名空间中的number = " << wd::number << endl;
    cout << "wh命名空间中的number = " << wd::wh::number << endl;
}
}//end of namespace wd
int main(void)
{
    using wd::display;
    display();
    return 0;
}

const关键字

const关键字修饰变量

const int number1 = 10;//const关键字修饰的变量称为常量
int const number2 = 20;

const int val;//error 常量必须要进行初始化
number1 = 20; //error 常量是不能赋值的

除此之外,宏定义也可以创造常量

#define NUMBER 1024
#define MUl(x, y) x * y //error 容易产生字符串替换后的运算顺序错误
#define MUl(x, y) ((x) * (y)) //需要加上括号来保证运算顺序

Q:const常量与宏定义的区别是什么?

  • 编译器处理方式不同。宏定义是在预处理阶段展开,做字符串的替换;而const常量是在编译时。
  • 类型和安全检查不同。宏定义没有类型,不做任何类型检查;const常量有具体的类型,在编译期会执行类型检查。

在使用中,应尽量以const替换宏定义,可以减小犯错误的概率。

const关键字修饰指针

区别常量指针(pointer to const)和指针常量(const pointer)

  • 常量指针:const位于*左边
  • 指针常量:*位于const左边
int number1 = 10;
int number2 = 20;
int number3 = 30;

//常量指针,Pointer to const
const int * p1 = &number1;
*p1 = 100;//error 通过p1指针无法修改其所指内容的值(*p1不能改变)
p1 = &numbers;//ok 可以改变p1指针的指向(p1可以改变)
int const * p2 = &number1; //常量指针的第二种写法

//指针常量,const pointer
int * const p3 = &number1;
*p3 = 100;//ok 通过p3指针可以修改其所指内容的值
p3 = &number2;//error 不可以改变p1指针的指向

//双const,既不能改变指针所指变量的值,也不能改变指针的指向(只读)
const int * const p4 = &number3;
cout << "*p4 = " << *p4 << endl;
*p4 = 20;//error,不可以改变指针所指变量的内容
p4 = &a;//error,不可以改变指针的指向
cout << "*p4 = " << *p4 << endl;

const直接修饰的对象不能改变

const关键字修饰成员函数

//函数指针
int (*func)()//func指针可以指向返回类型是int,参数为无参的函数

//指针函数
int*  func()//func函数的返回类型是int*,参数为无参

const关键字修饰对象

//数组指针
int (*array)[10]//array指针可以指向存放十个int类型数据的数组、

//指针数组
int*  array[10]//array数组可以存放十个int*类型指针

new与delete函数

  1. malloc函数与free函数

    malloc, free, calloc, realloc - allocate and free dynamic memory

    此处的dynamic memory指堆空间。

    int *pret = (int*)malloc(sizeof(int));//有void *malloc(size_t size);可以知道malloc返回类型为void,因此需要(int*)来进行强制转换。此时得到原始的未经初始化的空间
    memset(pret, 0, sizeof(int));//清零
    *pret = 20;//赋值
    
    ...
    
    free(pret);//回收malloc申请的堆空间
    pret = NULL;//NULL是C中的写法
    pret = nullptr;//在C++11中,空指针nullptr进行替代
    
    ...//还有可能会继续使用pret
    
  2. new函数与delete函数

    int *pInt = new int(10); //申请单个的堆空间,是int类型,初始值是10
    *pInt = 20;
    cout << "*pInt = " << *pInt << endl;
    
    ...
    
    delete pInt;//释放空间
    pInt = nullptr;
    
    int *pInt = new int[10]();//申请堆空间数组,是有10个int类型的数组。此处的小括号具备初始化的含义。
    
    delete [] pInt;//释放空间,为了表名是数组,需要在delete后面加上[],但是注意:delete之后的[]中不要加数字
    
  • malloc与free函数和new与delete函数之间的异同点

    • 相同点 1、都是用来申请堆空间的语法 2、都需要成对出现malloc/free, new/delete(不能交叉使用)
    • 不同点 1、mallocfree是C语言中的库函数,但是new/delete是C++中表达式 2、malloc申请是原始的未初始化的空间,但是new申请的空间是可以进行初始化
  • 内存泄露

    程序在申请内存后,无法释放已申请的内存空间。

  • 野指针

    指向的位置不可知的指针。

  • 内存溢出

    程序在申请内存时,没有足够的内存供申请者使用。

  • 内存踩踏

    访问了不合法的地址。

引用★

基本写法

int number = 10;
//引用是一个变量的别名,引用的提出是为了减少指针的使用,操作引用与操作变量本身是等价的。
int &ref = number;
int &ref2;//error 需要进行初始化
  • &在这里不再是取地址符号,而是引用符号
  • 声明引用的同时,必须对引用进行初始化;否则编译时报错
  • 引用不是一个对象,因此不能定义引用的引用。

基本概念与本质

引用不能单独存在,在定义的时候必须要进行初始化。

引用在定义的时候必须要绑定到一个变量上,一经绑定之后就不能改变引用的指向,引用的本质就是一个指针,本质是一个指针常量(* const) 。

由于引用是被限制的指针,所以引用是占据内存的,占据的大小就是一个指针的大小。引用变量会占据存储空间,存放的是一个地址,但是编译器阻止对它本身的任何访问,从一而终总是指向初始的目标单元。在汇编里, 引用的本质就是“间接寻址”。

引用作为函数参数

例:交换两个变量的值

//指针作为参数,指针传递
void swap(int *pa, int *pb)
{
    int temp = *pa;
    *pa = *pb;
    *pb = temp;
}

//引用作为参数,引用传递★
void swap(int &x, int &y)
{
    int temp = x;
    x = y;
    y = temp;
}

//变量作为参数,值传递(error),此时不能正确交换
void swap(int a, int b)
{
    int temp = a;
    a = b;
    b = temp;
}

当用引用作为函数的参数时,其效果和用指针作为函数参数的效果相当。当调用函数时,函数中的形参就会被当成实参变量或对象的一个别名来使用,也就是说此时函数中对形参的各种操作实际上是对实参本身进行操作,而非简单的将实参变量或对象的值拷贝给形参。

引用作为函数的返回值

int gNumber;//全局变量
int func1() // 当函数返回时,会对temp进行复制
{
    temp = 100;
    return temp;//此处会进行复制操作
}
int &func2()//当函数返回时,不会对temp进行复制,因为返回的是引用
{
    temp = 1000;
    return temp;
}
int &func3()
{
    int number = 1;
    return number;
}//error 不能返回局部变量的引用

int &func4()
{
    int *pInt = new int(1);
    return *pInt;
}//warning 返回new分配的堆空间变量的引用,如果忘记回收会发生内存泄露

void test()
{
    int a = 3, b = 4;
    int c = a + func4() + b;//发生内存泄露
    
    int &ref = func4();
    delete &ref;//回收内存
}

当以引用作为函数的返回值时,返回的变量其生命周期一定是要大于函数的生命周期的,即当函数执行完毕时,返回的变量还存在。

不能返回局部变量的引用。

  • 主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指"的引用,程序会进入未知状态。

不能在函数内部返回new分配的堆空间变量的引用。

  • 如果返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么该引用所在的空间就无法释放,会造成内存泄漏。
  • 不建议返回堆空间的引用,除非有自动内存回收的机制。
int func(x)
{
    int countx = 0;
    while(x)
    {
        countx++;
        x = x&(x-1);
    }
    return countx;
}

强制转换、函数重载、默认参数、内联函数、异常与字符串

强制转换

C语言的强制转换

TYPE a = TYPEEXPRESSION;
  • C风格的类型转换有不少的缺点,有的时候用c风格的转换是不合适的,因为它可以在任意类型之间转换,比如你可以把一个指向const对象的指针转换成指向非const对象的指针,把一个指向基类对象的指针转换成指向一个派生类对象的指针,这两种转换之间的差别是巨大的,但是传统的c语言风格的类型转换没有区分这些。
  • C风格的转换不容易查找,它由一个括号加上一个标识符组成,而这样的东西在C++程序里一大堆。

static_cast

最常用的类型转换符。在正常状况下的类型转换,用于将一种数据类型转换成另一种数据类型,如把int转换为float。

int iNumber = 100
float fNumber = 0

fNumber = (float) iNumber//C风格
fNumber = static_cast<float>(iNumber);//C++风格

也可以完成指针之间的转换,如将void*指针转换成其他类型的指针

void *pVoid = malloc(sizeof(int));
int *pInt = static_cast<int*>(pVoid);
*pInt = 1;

int *pInt = static_cast<int*>(malloc(sizeof(int)));//一步完成

但不能完成任意两个指针类型间的转换

int iNumber = 1;
int *pInt = &iNumber;
float *pFloat = static_cast<float *>(pInt);//error

总结,static_cast的用法主要有以下几种:

  • 用于基本数据类型之间的转换,如把int转成char,把int转成enum。这种转换的安全性也要开发人员来保证。
  • 把void指针转换成目标类型的指针,但不安全。
  • 把任何类型的表达式转换成void类型。
  • 用于类层次结构中基类和派生类之间指针或引用的转换。进行上行转换(把派生类的指针或引用转换成基类指针或引用)是安全的;进行下行转换(把基类指针或引用转换成派生类指针或引用)时,由于没有动态类型检查,所以是不安全的。

const_cast

该运算符用来修改类型的const属性。

常量指针被转化成非常量指针,并且仍然指向原来的对象;常量引用被转换成非常量引用,并且仍然指向原来的对象;常量对象被转换成非常量对象。

const int number = 100;
int *pInt = &number;//error
int *pInt2 = const_cast<int *>(&number);
*pInt2 = 30;

此时会发生很奇怪的现象。

number = 100
pInt2 = 30
&number = 0x7fffa1d1282c
pInt = 0x7fffa1d1282c
&pInt = 0x7fffa1d12830

pInt&number均指向同一个地址,但是其中的值却不同。在使用const_cast强制转换后再写入是一个未定义的行为,C++的语法不承认。

dynamic_cast

该运算符主要用于基类和派生类间的转换,尤其是向下转型的用法中。

reinterpret_cast

该运算符可以用来处理无关类型之间的转换,即用在任意指针(或引用)类型之间的转换,以及指针与足够大的整数类型之间的转换。

由此可以看出reinterpret_cast的效果很强大,但错误的使用reinterpret_cast很容易导致程序的不安全,只有将转换后的类型值转换回到其原始类型,这样才是正确使用reinterpret_cast方式。

函数重载★

C++允许多个函数拥有相同的名字(C语言不允许),只要它们的参数列表不同就可以,这就是函数重载(Function Overloading)。借助重载,一个函数名可以有多种用途。

函数重载是指在同一作用域内,可以有一组具有相同函数名,不同参数列表的函数,这组函数被称为重载函数。重载函数通常用来命名一组功能相似的函数,这样做减少了函数名的数量,避免了名字空间的污染,对于程序的可读性有很大的好处。

C++进行函数重载的实现原理叫名字改编(name mangling),具体的规则是:

  1. 函数名称必须相同 。
  2. 参数列表必须有所不同(参数的类型不同、个数不同、顺序不同)。
  3. 函数的返回类型可以相同也可以不相同。
  4. 仅仅返回类型不同不足以成为函数的重载。

有如下代码:

int add(int x, int y)
{
  return x + y;
}

float add(float x, float y)
{
  return x + y;
}

int add(int x, float y)
{
  return x + y;
}
int add(float x, int y)     
{
  return x + y;
}

int add(int x, int y, int z)
{
  return x + y + z;
}

float add(float x, float y, float z)
{
  return x + y + z;
}

void test()
{
    int * pInt= static_cast<int *>(malloc(sizeof(int)));
    memset(pInt, 0, sizeof(int));
    free(pInt);
    pInt = nullptr;
}

在进行g++只编译不链接后有文件add.o

g++ -c 3_add.c -o add.o

对add.o文件进行nm命令查看,有结果

nm add.o
                 U __cxa_atexit
                 U __dso_handle
                 U free
                 U _GLOBAL_OFFSET_TABLE_
000000000000014d t _GLOBAL__sub_I__Z3addii
00000000000000e9 T main
                 U malloc
                 U memset
0000000000000014 T _Z3addff
0000000000000082 T _Z3addfff
000000000000004a T _Z3addfi
000000000000002e T _Z3addif
0000000000000000 T _Z3addii
0000000000000066 T _Z3addiii
0000000000000104 t_Z41__static_initialization_and_destruction_0ii
00000000000000a6 T _Z4test
...

在7-12行可以看到其C++支持函数重载的原理是对函数名字进行改编。当函数名字相同的时候,会按照函数的名字加上参数列表的顺序、个数与类型进行改名,将函数的名字与参数列表合在一起重新取名。

在4-6行中可以看到C中的函数不会重新命名。

如果希望尝试用C进行编码。可以用extern关键字

extern "C"
{
int add(int x, int y)
{
    return x + y;
}
}//end of extern "C"

再次进行g++ -c只编译不链接,可以用nm查看有

nm add.o
0000000000000000 T add
                 U __cxa_atexit
                 U __dso_handle
                 U free
                 U _GLOBAL_OFFSET_TABLE_
000000000000014d t _GLOBAL__sub_I_add
00000000000000e9 T main
                 U malloc
                 U memset
0000000000000014 T _Z3addff
0000000000000082 T _Z3addfff
000000000000004a T _Z3addfi
000000000000002e T _Z3addif
0000000000000066 T _Z3addiii
...

此时有add在0000000000000000处,其按照C的方式进行编译,即不进行名字改编。

但是上面的代码不能在C的环境中正确编译,因此需要加上如下代码

#ifdef __cplusplus
extern "C"
{
#endif
int add(int x, int y)
{
    return x + y;
}
#ifdef _cplusplus
}//end of extern "C"
#endif

此时C++编译器会正确运行#ifdef __cplusplus#endif内置宏,因此会包括extern “C”{}//end of extern "C",而C编译器不认识,不会包含此部分。

此方法称为C与C++的混合编码方式。

默认参数

默认参数的目的

C++可以给函数定义默认参数值。

void func1(int 1 x = 0, int y = 0);

void func1(int x, int y)
{
cout << "x = " << x << endl;
cout << "y = " << y << endl;
}

这样调用时,若不给参数传递实参,则func1函数会按指定的默认值进行工作。允许函数设置默认参数值,是为了让编程简单,让编译器做更多的检查错误工作。

默认参数的声明

一般默认参数在函数声明中提供。当一个函数既有声明又有定义时,只需要在其中一个中设置默认值即可。

我们通常是将默认值的设置放在声明中而不是定义中。

若在定义时而不是在声明时置默认值,那么函数定义一定要在函数的调用之前。因为声明时已经给编译器一个该函数的向导,所以只在定义时设默认值时,编译器只有检查到定义时才知道函数使用了默认值。若先调用后定义,在调用时编译器并不知道哪个参数设了默认值。

默认参数的顺序规定

如果一个函数中有多个默认参数,则形参分布中,默认参数应从右至左逐渐定义。当调用函数时,只能向左匹配参数。

原因是参数是按照从右向左入栈的。

void func2(int a = 1, int b, int c = 0, int d);//error
void func2(int a, int b, int c = 0, int d = 0);//ok

若给某一参数设置了默认值,那么在参数表中其后所有的参数都必须也设置默认值,否则,由于函数调用时可不列出已设置默认值的参数,编译器无法判断在调用时是否有参数遗漏。

默认参数与函数重载

如果一组重载函数(可能带有默认参数)都允许相同实参个数的调用,将会引起调用的二义性。所以在函数重载时,要谨慎使用默认参数。

bool类型

在C++中,还添加了一种基本类型,就是bool类型,用来表示true和false。true和false是字面值,可以通过转换变为int类型,true为1,false为0。

任何非零值都将转换为true,而零值转换为false。

bool b1 = -100;//true
bool b2 = 100;//true
bool b3 = 0;//false
bool b4 = 1;//true
bool b5 = true;//true
bool b6 = false;//false
int x = sizeof(bool);//1B

一个bool类型的数据占据的内存空间大小为1B。

内联函数★

一般可以用如下函数来求两个数的较大值。

int max(int x, int y)
{
return x > y ? x : y;
}

调用函数比求解等价表达式要慢得多。

在大多数的机器上,调用函数都要做很多工作:调用前要先保存寄存器,并在返回时恢复,复制实参,程序还必须转向一个新位置执行。对于这种简短的语句使用函数开销太大。

什么是内联函数?

内联函数是C++的增强特性之一,用来降低程序的运行时间。

当内联函数收到编译器的指示时,即可发生内联:编译器将使用函数的定义体来替代函数调用语句,这种替代行为发生在编译阶段而非程序运行阶段。定义函数时,在函数的最前面以关键字“inline”声明函数,即可使函数为内联声明函数。

inline int max(int x, y)
{
    return x > y ? x : y;
}

内联函数和带参数的宏定义

  1. 宏定义的缺点

    • 容易出错
    • 不可调试
    • 无法操作类的私有数据成员
  2. 内联函数的优点

    • 编译器可以通过上下文相关的优化技术对结果代码执行更深入的优化
    • 既具有宏代码的效率,又增加安全性

除了断言assert之外,其他C++程序中应该用内联函数尽可能代替宏定义。

将内联函数放入头文件

关键字 inline 必须与函数定义体放在一起才能使函数成为内联,仅将 inline 放在函数声明前面不起任何作用。

如下的foo函数是错误的;

inline void foo(int x, int y);//error 该语句在头文件中
void foo(int x, int y)//实现在.cpp文件中
{
//...
}

应该是:

inline void foo(int x, in y)//该语句在头文件中
{
//...
}

C++的inline函数是一种“用于实现的关键字”,而不是一种“用于声明的关键字”。一般地,用户可以阅读函数的声明,但是看不到函数的定义。

有如下的文件

.
├── add.cc
├── add.h
└── testAdd.cc

其中add.h文件为

#ifndef __ADD_H__ //防止在同一个文件中被多次编译
#define __ADD_H__

//对于内联函数而言,不能分成头文件与实现文件,也就是不能将声明与实现分开,否则在链接的时候,会链接不到函数的实现。
//如果一定要将头文件与实现文件分开,那么可以在头文件中去#include实现文件,可以弥补链接不到add.o的缺陷
inline
int add(int, int);

#include "add.cc"
#endif

add.cc文件为

inline
int add(int x, int y)
{
    return x + y;
}

testAdd.cc文件为

#include "add.h"
#include "add.h"
#include <iostream>
#include "add.h"//此时由于有#ifndef __ADD_H__在add.h中的存在,因此只会编译一次

using std::cout;
using std::endl;                                                                       

int main(int argc, char *argv[])
{
    int a = 3, b = 4;
    cout << "add(a, b) = " << add(a, b) << endl;
    return 0;
}

谨慎使用内联函数

内联函数适用于小函数,即函数体比较简单的函数。

  • 不能有函数调用
  • 不能有for/while循环或其他复杂的控制结构
  • 谨慎用于构造函数和析构函数

现代的编译器比较智能,能够自动识别函数是否比较适宜变成内联函数。一个好的编译器会自动取消一个函数的内联请求,自动取消不值得的内联或者自动内联溢写没有inline请求的函数。

异常安全

异常是程序在执行期间产生的问题。C++ 异常是指在程序运行时发生的特殊情况,比如尝试除以零的操作。异常提供了一种转移程序控制权的方式。

C++ 异常处理涉及到三个关键字:try、catch、throw。

  • throw: 当问题出现时,程序会抛出一个异常。我们说throw引发(raise)了异常。
  • try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块。
  • catch: 在您想要处理问题的地方,通过异常处理程序捕获异常。catch 关键字用于捕获异常。

try-catch语句块

void test()
{
    double x, y;
    cin >> x >> y;
    try
    {
        if(0 == y)
        {
            throw y;
        }
        else
        {
            cout << (x / y) << endl;
        }
    }
    catch(double d)
    {
        cout << "catch(double)" << endl;
    }
    catch(int e)
    {
        cout << "catch(int)" << endl;
    }
}

此示例中catch(int e)肯定不会触发。理解即可。

C++中的字符串

C风格字符串

查看长度

字符串处理在程序中应用广泛,C风格字符串是以\0(空字符)来结尾的字符数组或字符指针。

对字符串进行操作的C函数定义在头文件<string.h>中。

理解字符数组与字符指针:

void Diff()
{
    //字符数组
    char str1[] = "hello";
    char str2[] = {'w', 'o', 'r', 'l', 'd'};
    char str3[] = {'w', 'o', 'r', 'l', 'd','\0'};

    cout << "strlen(str1) = " << strlen(str1) << endl; 
    cout << "strlen(str2) = " << strlen(str2) << endl;
    cout << "strlen(str3) = " << sizeof(str2) << endl;
    
    cout << "sizeof(str1) = " << sizeof(str1) << endl;
    cout << "sizeof(str2) = " << sizeof(str2) << endl;
    cout << "sizeof(str3) = " << sizeof(str2) << endl;
    cout << endl;
    
    //字符指针
    char *pstr = "hello,world";                                                            
    cout << "strlen(pstr) = " << strlen(pstr) << endl;
    cout << "sizeof(pstr) = " << sizeof(pstr) << endl;
    cout << "sizeof(*pstr) = " << sizeof(*pstr) << endl;
    cout << "*pstr = " << *pstr << endl;
}

编译运行后有

./a.out
strlen(str1) = 5  //使用strlen时会遇到\0时会自动结束,不包含\0
strlen(str2) = 10 //错误结果 需要手动加上\0作为结尾
strlen(str3) = 5  //与str1相同
sizeof(str1) = 6  //C语言风格的字符串以\0结尾
sizeof(str2) = 5  
sizeof(str3) = 6

strlen(pstr) = 11
sizeof(pstr) = 8  //此处指的是指针的大小
sizeof(*pstr) = 1 //sizeof(*pstr)表示指向的第一个字符的大小
*pstr = h         //*pstr表示指向的第一个字符

sizeof测试指针的时候,不能将指针所指的内容长度计算出来,只能计算指针的大小。对于32位系统而言,一个指针是4字节,对于64位系统而言,一个指针是8字节。

总的来说,用字符数组的时候用sizeof来测试大小,用字符指针时候用strlen来测试大小。

字符数组与字符指针

此外,字符数组可以修改数组中的内容,不能改变指向,而字符指针可以改变指向而不能修改内容。

可以类比为指针常量与常量指针。

//字符数组,类比指针常量int * const p3
str1[0] = 'H';//ok 修改数组中的内容
cout << "str1 = " << str1 << endl;
str1 = nullptr;//error 不能修改指向

//字符指针,类比常量指针const int * p3
char *pstr = "hello,world";
cout << "pstr = " << pstr << endl;
cout << "strlen(pstr) = " << strlen(pstr) << endl;
pstr[0] = 'H';//warning 会发生段错误(Segmentation fault)
pstr = "Lizi" //warning 可以改变指向

可以使用gdb进行调试

g++ 12_string.cc -o string -g //会报warning
gdb ./string

会出现如下错误

(gdb) n

Program received signal SIGSEGV, Segmentation fault.
0x0000555555554c5a in test () at 12_string.cc:27
27	    pstr[0] = 'H';//error 会发生段错误(Segmentation fault)

想要解决此问题,可以在字符指针定义的时候加上const关键字进行修饰

//字符指针,类比常量指针const int * p3
const char *pstr = "hello,world";
cout << "pstr = " << pstr << endl;
cout << "strlen(pstr) = " << strlen(pstr) << endl;
pstr[0] = 'H';//error 会发生段错误(Segmentation fault)
pstr = "Lizi" //ok 可以改变指向
字符串常用库函数
//字符检查函数(非修改式操作)
size_t strlen( const char *str );//返回str的长度,不包括null结束符
//比较lhs和rhs是否相同。lhs等于rhs,返回0; lhs大于rhs,返回正数; lhs小于rhs,返回负数
int strcmp( const char *lhs, const char *rhs );
int strncmp( const char *lhs, const char *rhs, size_t count );
//在str中查找首次出现ch字符的位置;查找不到,返回空指针
char *strchr( const char *str, int ch );
//在str中查找首次出现子串substr的位置;查找不到,返回空指针
char *strstr( const char* str, const char* substr );
//字符控制函数(修改式操作)
char *strcpy(char *dest, const char *src);//将src复制给dest,返回dest
char *strncpy(char *dest, const char *src, size_t count);
char *strcat( char *dest, const char *src );//concatenates two strings
char *strncat( char *dest, const char *src, size_t count );

举例,我们尝试将两个字符串进行拼接

strcat(str1, str3);//error 发生内存踩踏

但是这个是有问题的,此时会发生内存踩踏。应该修改为:

size_t len = sizeof(str1) + sizeof(str3)
char *ptmp = new char[len]();
strcpy(ptmp, str1);
strcat(ptmp, str3);

值得注意的是,如果是str1与str2这个未加上\0的错误的字符数组进行拼接,那么拼接结果也会错误。

C++风格字符串

C++提供了std::string(后面简写为string)类用于字符串的处理。string类定义在C++头文件中,注意和头文件区分,其实是对C标准库中的<string.h>的封装,其定义的是一些对C风格字符串的处理函数。

我们先来看一个简单的例子:

using std::string;
using std::cout;
using std::endl;

void string_test()
{
    //C风格字符串转换为C++风格字符串
    string s1 = "hello";
    string s2("world");
    
    //求取字符串长度
    cout << s1.size() << endl;
    cout << s1.length() << endl;
    
    //字符串的遍历
    for(size_t idx = 0; idx != s1.size(); ++idx)
    {
        cout << s1[idx] << " ";
    }
    cout << endl;
    
    //字符串拼接
    string s3 = s1 + s2;
    cout << "s3 = " << s3 << endl;
    
    //查找子串
    size_t pos = s1.find("world");
    
    //截取子串
    string substr = s1.substr(pos);
    cout << "substr = " << substr << endl;
        
    //string与C风格字符串的转换
    const char *c_str() const;// 返回一个C风格字符串
    const char *data() const;// c++11之后与c_str()效果一致
    size_type copy(char* dest, size_type count, size_type pos = 0) const;//字符串的内容复制或写入既有的C风格字符串或字符数组内
}

还有很多很多的库函数可以调用。可以参考C++ reference进行查找与学习。

内存分配方式

内存分配方式★★

可以参考《操作系统导论》和《现代操作系统》,或者直接参考408考研王道咸鱼的视频——3.1.1_3进程的内存映像。

理解下面这个图:

faf6401243c54728b1e2117283021ffe

如果有以下代码

#include <stdio.h>

#define X 1024//预处理时替换
int a = 1;//读写段
const int b = 2;//只读数据区(文字常量区)

int main(){//main函数在程序代码区
    static int c = 3;//读写段
    int d =4;//栈区
    const int e = 5;//栈区
    const char *pstr = "Hello";//&pstr在栈区,pstr在只读数据区
    int *p = (int*)malloc(sizeof(int)*10);//&p在栈区,p在堆区
    
    a = b + c + d;
    for(int i = 0; i < 10; i++){
        p[i] = X+i;
    }
    printf("world\n");//printf函数在共享库的存储映射区
    free(p);
    
    return 0;
}

由低地址向高地址有

  • 未使用区

  • 只读段

    • 程序代码区:程序的二进制代码
    • 只读数据区:字符串常量(由const修饰的b,由const修饰的pstr
  • 读写段:全局变量(a)、静态变量(由static修饰的c

只读段和读写段在进程启动的时候即可确定大小,固定不变。

  • 堆(heap):malloc/free/new/delete分配回收的区域,由程序员进行分配和释放(pInt指针指向变量的地址)
  • 共享库的存储映射区:被调用的库函数(printf函数的代码)
  • 栈(stack):由各函数的栈帧组成,包含各个局部变量(d,由const修饰的ep指针本身的地址)、函数调用相关的信息,由编译器分配和释放
  • 操作系统内核区:内核代码、内核数据结构等。如进程控制块PCB

此外值得注意的是宏定义(#define X 1024)是在预处理阶段展开,做字符串的替换。

构造函数、析构函数、浅拷贝与深拷贝、this指针与赋值运算符函数

C++中类的定义

类与对象

  • C语言:面向过程的语言,按照事物发展的逻辑顺序进行

  • C++语言:面向对象的语言。基本特征:抽象、封装、继承、多态

类是由多个具有相同属性的对象抽象出来的。类的定义分为两个部分:数据(相当于属性)和对数据的操作(相当于行为)。

{
    //属性:特征,即数据类型
    //方法:函数,即成员函数
}

从程序设计的观点来说,类就是数据类型,是用户定义的数据类型,对象可以看成某个类的实例(某个类的变量)。类是对象的封装,对象是类的实例。

C++中用关键字class来定义一个类,其基本形式如下:

class 类名
{
public://公有数据成员和成员函数
    
protected://保护数据成员和成员函数
    
private://私有数据成员和成员函数
    
}; //千万不要忘了这个分号

举例:

#include <string.h>
#include <iostream>

using std::cout;
using std::endl;

class Computer
{
public:
    //成员函数
    void setBrand(const char * brand)
    {
    	strcpy(_brand, brand);//没有考虑内存越界的问题
        //tips:可以用快捷键shift+k查看strcpy,相当于man strcpy,此外想要到不同卷,可以[n], shift+k
    }
    void setPrice(float price)
    {
   		_price = price;
	}
    void print()
    {
        cout << "brand = " << _brand << endl;
        cout << "price = " << _price << endl;
    }
    
private:
    //数据成员
    char _brand[20];
    float _price;
};

int main(int argc, char * argv[])
{
    Computer pc;//通过类来创建对象,无参时不需要加上(),否则是函数声明
    pc.setBrand("Huawei Matebook14");
    pc.setPrice(5600);
    pc.print();
    pc._price = 5500;//error 私有数据不能在类外部用
    return 0;
}

空类

空类有什么?

空类也会自动生成4个成员函数:构造函数、析构函数、拷贝构造函数和赋值运算符函数。

class Empty
{
}//此时有四个成员函数
  • 空类的大小为1B(用于区分不同的对象)。

  • 空类可以创建对象。

    Empty em1;
    Empty em2;
    
    printf("&em1 = %p\n", &em1);
    printf("&em2 = %p\n", &em2);
    

    此时的地址不同。

成员函数可以在类之外实现

成员函数还可以在类之外实现。在类定义的外部定义成员函数时,应使用作用域限定符(::)来标识函数所属的类,即有如下形式:

返回类型 类名::成员函数名(参数列表)
{
	//....
}

class部分可以修改为

class Computer
{
public:
    void setBrand(const char * brand);
    void setPrice(float price);
    void print();
    
private:
    char _brand[20];
    float _price;
};

void Computer::setBrand(const char * brand)
{
    strcpy(_brand, brand);//没有考虑内存越界的问题
}
void Computer::setPrice(float price)
{
    _price = price;
}
void Computer::print()
{
    cout << "brand = " << _brand << endl;
    cout << "price = " << _price << endl;
}

一般地,我们会将文件分为头文件(.h)、实现文件(.cc)和测试文件(.cc)。

此时,头文件Computer.h中有

#ifndef _COMPUTER_H_
#define _COMPUTER_H_

class Computer
{
public:
    void setBrand(const char * brand);
    void setPrice(float price);
    void print();
    
private:
    char _brand[20];
    float _price;
};

#endif

实现文件Computer.cc中有

#include "Computer.h"
#include <string.h>
#include <iostream>

using std::cout;
using std::endl;

void Computer::setBrand(const char * brand)
{
    strcpy(_brand, brand);//没有考虑内存越界的问题
}
void Computer::setPrice(float price)
{
    _price = price;
}
void Computer::print()
{
    cout << "brand = " << _brand << endl;
    cout << "price = " << _price << endl;
}

测试文件testComputer.cc有

#include "Computer.h"

int main(int argc, char * argv[])
{
    Computer pc;//通过类来创建对象
    pc.setBrand("Huawei Matebook14");
    pc.setPrice(5600);
    pc.print();
    
    return 0;
}

此时,声明、实现和测试都分开了。编译的时候用g++ Computer.cc testComputer.cc来编译。如果分成头文件与实现文件的话,成员函数不能是inline函数。

struct与class的区别

在C++中,与C相比,struct的功能已经进行了扩展。因此只有唯一区别:

  • struct的默认访问权限是共有的public,而class的默认访问权限是私有的private。

构造函数

构造函数的作用是给数据成员进行初始化

  • 构造函数在对象创建的时候会自动调用
  • 构造函数的名字与类名相同
  • 构造函数没有返回类型
  • 构造函数可以重载

这里定义一个比较简单的类Point

class Point
{
public:
    //...
    Point(int ix, int iy)//构造函数名需要与类名相同
    : _ix(ix)//等价于int _ix = ix的语法
    , _iy(iy)
    {
        cout << "Point(int,int)" << endl;
    }
    
    Point()//构造函数可以重载
    {
        cout << "Point()" << endl;
        _ix = 0;//此时不是初始化,而是赋值
        _iy = 0;
    }
    
    void print()
    {
        cout << "(" << _ix
        << "," << _iy
        << ")" << endl;
    }
private:
    int _ix;
    int _iy;
};

int main(void)
{
    Point pt(1, 2);
    pt.print();
    
	Point pt2();
    pt2.print();
    
    return 0;
}

编译器会自动给类生成一个默认构造函数,这是有前提条件的,就是类中没有定义任何构造函数。

Point()//编译器默认提供的构造函数,不会对值进行初始化
{
}

现在假设类中只显式定义了一个有参构造函数,则编译器不会再自动提供默认构造函数。如果还希望通过默认构造函数创建对象,则需要显式定义一个默认构造函数。

如果希望能够一次性定义构造函数,可以使用默认参数。

Point(int ix = 0, int iy = 0);//在声明中设置默认参数

Point(int ix, int iy)
: _ix(ix)
, _iy(iy)
{
    cout << "Point(int,int)" << endl;
}

此时PointPoint(ix)Point(iy)Point(ix, iy)均可使用。

初始化表达式

初始化列表位于构造函数形参列表之后,函数体之前,用冒号开始,如果有多个数据成员,再用逗号分隔,初始值放在一对小括号中。

此时,依照构造函数和初始化表达式,我们可以再次优化头文件Computer.h中的代码为

class Computer
{
public:
    Computer(const char *brand, float price);
    void print();
    
private:
    char *brand;
    float _price;
};

实现文件Computer.cc代码为

#include "Computer.h"
#include <string.h>
#include <iostream>

using std::cout;
using std::endl;

Computer::Computer(const char *brand, float price)
: _brand(new char[strlen(brand) + 1]()//申请空间
, _price(price)
{
    strcpy(_brand, brand);//此时已经考虑了内存越界
}

void Computer::print()
{
    cout << "brand = " << _brand << endl;
    cout << "price = " << _price << endl;
}

测试文件testComputer.cc代码为

#include "Computer.h"

int main(int argc, char * argv[])
{
    Computer pc("Huawei Matebook14", 6800);
    pc.print();
    return 0;
}

但是其实代码还是有些问题。此时实现文件Computer.cc中new的空间没有被释放(需要析构函数),发生内存泄露。

此外,需要注意:每个成员在初始化列表之中只能出现一次,其初始化的顺序不是由成员变量在初始化列表中的顺序决定的,而是由成员变量在类中被声明时的顺序决定的,如下:

class Foo
{
public:
    Foo(int a)
    : _iy(a)
    , _ix(_iy)//_ix先被初始化,说明只与被声明的顺序有关
    {
    	cout << "Foo(int)" << endl;
    }
private:
    int _ix; //在声明时,_ix在前
    int _iy;
};

6. 析构函数

特点

析构函数(Destructor)的特点是为了清除数据成员。

  • 析构函数在对象被撤销时被自动调用
  • 与类同名,之前冠以波浪号,以区别于构造函数
  • 析构函数没有返回类型,也不能指定参数
  • 析构函数只能有一个,不能被重载
Computer::~Computer()
{
    
}
class Computer
{
public:
    Computer(const char *brand, double price)
    : _brand(new char[strlen(brand) + 1]())
    , _price(price)
    {
        cout << "Computer(const char *, double)" << endl;
    }
    
    ~Computer()//对象销毁时自动调用析构函数,不可重载
    {
        cout << "~Computer()" << endl;
        delete [] _brand;//调用两次析构函数时,第二次会输出错误结果
        _brand = nullptr;//如果没有这一句,调用两次析构函数时会发生double free错误
    }

private:
    char *_brand;
    double _price;
};

有些类比较简单,只包含栈对象、全局对象、静态对象,数据成员中没有需要进行清理的资源,所以即使不显式定义析构函数,也没关系。

但是有些时候默认析构函数是没有作用的,特别是对于堆空间的形式,需要人为定义析构函数执行delete操作。以上的Computer类中,有一个数据成员是指针,而该指针在构造函数中初始化时已经申请了堆空间的资源,则当对象被销毁时,必须回收其资源。此时,编译器提供的默认析构函数是没有做回收操作的,因此就不再满足我们的需求,我们必须显式定义一个析构函数,在函数体内回收资源。

  • 析构函数可以显式调用,构造函数不可以

  • 构造函数可以直接调用,会创造对象

    pc.~Point();//ok 可以显式调用析构函数
    pc.Point();//error 不可以用“对象.构造函数”的方式调用构造函数
    Point();//ok 可以通过调用构造函数创建对象
    

虽然可以显式调用析构函数,但是不建议手动调用,会发生调用两次析构函数的情况,如果程序鲁棒性不够好的话,会出现错误。

析构函数的使用场景

测试文件testComputer.cc代码为

#include "Computer.h"

Computer g_pc("Macbook", 10000);//g_pc是全局变量,在读写段,在运行main函数之前被创建,在main函数结束之后被销毁
int main(int argc, char * argv[])
{
    Computer pc_stack("Huawei Matebook14", 6800);//pc_stack在栈区,将pc称作栈对象,生命周期从到达该对象定义处到作用域结束
    Computer *pc__heap = new Computer("Xiaomibook",6000); //pc__heap是堆对象,不会自动调用析构函数
    
    pc_stack.print();
    
    delete pc__heap;
    pc__heap = nullptr;
    
    return 0;
}
  • 对于全局定义的对象,每当程序开始运行,在主函数main接受程序控制权之前,就调用构造函数创建全局对象,整个程序结束时,自动调用全局对象的析构函数。
  • 对于局部定义的栈对象,每当程序流程到达该对象的定义处就调用构造函数,在程序离开局部对象的作用域时调用对象的析构函数。
  • 对于关键字static定义的静态局部变量,当程序流程第一次到达该对象定义处调用构造函数,在整个程序结束时调用析构函数。
  • 对于用new运算符创建的堆对象,每当创建该对象时调用构造函数,当用delete删除该对象时,调用析构函数。

拷贝构造函数

浅拷贝与深拷贝

如果类中没有显式定义拷贝构造函数时,编译器会自动提供一个缺省的拷贝构造函数。

Point::Point(const Point &rhs)
: _ix(rhs._ix)//此时属于特别情况能够调用其他对象的私有数据成员
, _iy(rhs._iy)
{
}

由于Point的成员比较简单,缺省的拷贝构造函数已经可以满足需求了,所以可以不显式定义。

但对于Computer类,就不能使用缺省拷贝构造函数。

Computer::Computer(const Computer &rhs)
: _brand(rhs._brand)//浅拷贝
, _price(rhs._price)
{
}

Computer pc1("Huawei Matebook14", 5699);
Computer pc2 = pc1;//拷贝构造函数

//error double free

在作用域结束后,会自动调用析构函数~Computer,此时pc2会先调用(栈先进后出),将pc2._brand指向的空间释放,随后pc1进行析构函数~Computer调用后,会释放pc1._brand的空间,但是由于是浅拷贝,pc1._brand与pc2._brand指向的空间相同,因此会发生double free错误。

从上面的定义来看,pc1与pc2对象的数据成员_brand都会指向同一个堆空间的字符串,这种只拷贝指针地址的方式,我们称为浅拷贝

对于堆空间,应该使用深拷贝,即拷贝指针所指空间内容的方式。

Computer::Computer(const Computer & rhs)
: _brand(new char[strlen(rhs._brand) + 1]())
, _price(rhs._price)
{
    strcpy(_brand, rhs._brand);
}

拷贝调用函数的调用时机

  • 当用一个已经存在的对象初始化另一个新对象时,会调用拷贝构造函数。

    Computer pc2 = pc1;
    
  • 当实参和形参都是对象,进行实参与形参的结合时,会调用拷贝构造函数。

    void func(Point pt)//被调用时会进行实参与形参的结合,即Point pt = pt1
    {
        cout << "pt = ";
        pt.print();
    }
    
    void func2()
    {
        Point pt1(3,4);
        func(pt1);
    }
    
  • 当函数的返回值是类,函数调用完成返回时,会调用拷贝构造函数。(编译器会自动优化导致看不到,此时需要g++ Point.cc -fno-elide-constructors)

    Point func()
    {
        Point pt1(3,4);
        func(pt1);
    }
    
    void func2()
    {
        Point pt1 = func();//此时会发生两次拷贝调用函数的调用,一次是函数调用返回时调用拷贝构造函数,另一次是用一个已经存在的对象初始化另一个新对象时,会调用拷贝构造函数
        pt1.print();
    }
    

    func的返回值是一个临时对象,生命周期只在该行。

参数问题★

拷贝构造函数的参数形式不可以改变。

  • 引用符号&不可以去掉

    Point::Point(const Point rhs)//error
    : _ix(rhs._ix)
    , _iy(rhs._iy)
    {
    }
    

    非引用传参还是复制。此时如果调用Point拷贝构造函数,会发现又满足拷贝调用函数的调用时机,即当实参和形参都是对象,进行实参与形参的结合时,会调用拷贝构造函数,最终会发生循环调用,最终导致栈溢出。

  • const关键字也不可以去掉

    Point::Point(Point &rhs)//error
    : _ix(rhs._ix)
    , _iy(rhs._iy)
    {
    }
    

    如果传递的是右值的话,不能将非const左值引用绑定到右值上(cannot bind non-const lvalue reference to an rvalue)。

    • 左值lvalue:可以进行取地址的就是左值。
    • 右值rvalue:不可以进行取地址的就是右值。包括临时变量、临时对象、匿名对象、字面值常量(数字1,2…)

隐含的this指针★

有如下print函数

void Point::print()
{
    cout << "(" << _ix
    << "," << _iy
    << ")" << endl;
}

在通过对象名调用成员函数后,能准确地访问相应对象的成员。

Point pt(1, 2);
pt.print(); //(1, 2)
Point pt2(11, 12);
pt2.print(); //(11, 12)

其实在类中定义的非静态成员函数中都有一个隐含的this指针,它代表的就是当前对象本身,它作为成员函数的第一个参数,由编译器自动补全。其实print函数是这样的:

void Point::print(Point *const this)//this指针是一个指针常量
{
    cout << "(" << this->_ix
    << "," << this->_iy
    << ")" << endl;
}

this指针隐藏在非静态成员函数的第一个参数位置。

赋值运算符函数

基本用法

Point pt1(1, 2), pt2(3, 4);
pt1 = pt2;//赋值操作
pt1.operator=(pt2);
pt3 = Point(5, 6);

如上的赋值运算符函数需要与Point pt2 = pt1进行区分,此时不存在对象的构造。在这里,当=作用于对象时,其实是把它当成一个函数来看待的。也就是说,其实pt1 = pt2相当pt1.operator=(pt2)函数。

如果类中没有显式定义赋值运算符函数时,编译器会自动提供一个缺省的赋值运算符函数。对于Point类而言,其实现如下:

Point &Point::operator=(const Point &rhs)
{
    _ix = rhs._ix;//此时为赋值,不应该写成初始化形式
    _iy = rhs._iy;
    return *this;
}

堆空间使用深拷贝

但是如果在Computer类的赋值运算符函数实现,会出现错误:

Computer &Computer::operator=(const Computer &rhs)
{
    _brand = rhs._brand;
    _price = rhs._price;
    return *this;
}

此时使用该赋值运算符函数,会采取浅拷贝的方式拷贝字符串。当其中一个对象被销毁时,另一个对象一样获取不到_brand值,并且在执行析构函数时发生double free错误。

此时需要显式定义赋值运算符函数,但如果直接使用strcpy函数进行来拷贝,也是有问题的:

Computer &Computer::operator=(const Computer &rhs)
{
    strcpy(_brand, rhs._brand);
    _price = rhs._price;
    return *this;
}

此时会发生内存越界。

因此最好的方法是赋值的时候先销毁原来的堆空间,再申请一块大小与rhs._brand相同的堆空间,最后将rhs._brand值进行深拷贝:

Computer &Computer::operator=(const Computer &rhs)
{
    if(this != &rhs) //1、解决自复制问题
    {
        delete [] _brand; //2、释放左操作数
        _brand = nullptr;
        _brand = new char[strlen(rhs._brand) + 1](); //3、深拷贝
        strcpy(_brand, rhs._brand);
        _price = rhs._price;
    }
    return *this;
}
  • 如果没有if(this != &rhs)的话,会发生段错误(Segmentation fault)。此时会先释放_brand指向的空间,再将_brand指针置为空指针,此时rhs._brand即_brand,那么执行strcpy(_brand, rhs_brand)就会出错。
  • 不能将if(this != &rhs)写成if(*this != rhs),因为对象和对象之间不一定能进行比较。

参数问题

赋值运算符函数的参数形式不建议改变。

  • 参数中的引用符号&不建议去掉

    Point &Point::operator=(const Point rhs)
    {
        _ix = rhs._ix;
        _iy = rhs._iy;
        return *this;
    }
    

    此时会多执行一次拷贝构造函数(当实参和形参都是对象,进行实参与形参的结合时调用)。会增加函数的调用开销,因此不建议去掉引用符号。

  • const不可以去掉

    Point &Point::operator=(Point &rhs)
    {
        _ix = rhs._ix;
        _iy = rhs._iy;
        return *this;
    }
    

    会出现错误,不能将非const左值引用绑定到右值上。在执行pt3 = Point(1, 4);时候Point(1, 4)是右值,pt3为左值。

  • 返回类型的引用符号&不建议去掉

    Point Point::operator=(const Point &rhs)
    {
        _ix = rhs._ix;
        _iy = rhs._iy;
        return *this;
    }
    

    此时会多执行一次拷贝构造函数(当函数的返回值是类,函数调用完成返回时,会调用拷贝构造函数)。会增加函数的调用开销,因此不建议去掉引用符号。

  • 返回类型必须是类

    void &Point::operator=(const Point &rhs)
    {
        _ix = rhs._ix;//此时为赋值,不应该写成初始化形式
        _iy = rhs._iy;
        return *this;
    }
    

    如果不是类的话,不支持连等。

    pt4 = pt2 = Point(1, 4);//error: no match for "operator="(oprand types are 'Point' adn 'void')
    

综上,有标准的赋值运算符函数头为Point &Point::operator=(const Point &rhs)。不建议修改。

例:String的实现

#include <iostream>
#include <string.h>

using std::cout;
using std::endl;

class String
{
public:
	String(const char *pstr = nullptr);
	String(const String &rhs);
	String &operator=(const String &rhs);
	~String();
	void print();
    size_t length() const;
    const char * c_str() const;

private:
	char * _pstr;
    int _length;
};

String::String(const char *pstr){
    if(pstr == nullptr){
        _pstr = new char[1];
        *_pstr = '\0';
        _length = 0;
    }
    else{
        _length = strlen(pstr);
        _pstr = new char[_length+1];
        strcpy(_pstr,pstr);
    }
    cout << "String()" << endl;
}


String::~String(){
    delete [] _pstr;
    cout << "~String()" << endl;
}

String &String::operator=(const String &rhs){
    if(this == &rhs)
        return *this;
    delete [] _pstr;
    _length = strlen(rhs._pstr);
    _pstr = new char[_length + 1];
    strcpy(_pstr, rhs._pstr);
    cout << "operator=(const String &)" << endl;
    return *this;
}

String::String(const String &rhs){
    _length = rhs._length;
    _pstr = new char[_length+1];
    strcpy(_pstr, rhs._pstr);
    cout << "String(const String &)" << endl;
}

void String::print(){
    cout << "print():";
    cout << _pstr << endl;
}

size_t String::length() const{
    cout << "length():" << _length << endl;
    return _length;
}

const char* String::c_str() const{
    cout << "c_str():" << _pstr << endl;
    return _pstr;
}

int main()
{
	String str1;
	str1.print();

	String str2 = "Hello,world";
	String str3("wangdao");

	str2.print();
	str3.print();

	String str4 = str3;
	str4.print();
	str4 = str2;
	str4.print();
    str4.length();
    str4.c_str();

	return 0;
}

在经过编译运行后,有结果为

lizi@virtual:CppBaseDay09$./a.out 
String()
print():
String()
String()
print():Hello,world
print():wangdao
String(const String &)
print():wangdao
operator=(const String &)
print():Hello,world
length():11
c_str():Hello,world
~String()
~String()
~String()
~String()

特殊数据成员、特殊成员函数、对象的组织与单例模式

特殊数据成员

在C++的类中,有4种比较特殊的数据成员,他们分别是常量成员、引用成员、类对象成员和静态成员,他们的初始化与普通数据成员有所不同。

const数据成员

当数据成员用const关键字进行修饰以后,就成为常量成员。一经初始化,该数据成员便具有“只读属性”,在程序中无法对其值修改。事实上,在构造函数体内初始化const数据成员是非法的,它们只能在构造函数初始化列表中进行初始化。如:

class Point
{
public:
    //错误写法
    Point(int ix = 0, int iy = 0)
    {
        _ix = ix;//error, 这是赋值语句,const成员不能修改
        _iy = iy;//error
        _iz = _ix;
    }
    //正确写法
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    , _iz(_ix)
    {
    }
private:
    const int _ix;
    const int _iy;
    int & _iz;
};

引用数据成员

和常量成员相同,引用成员也必须在构造函数初始化列表中进行初始化

类对象成员

数据成员本身也是自定义类类型对象时,必须要在构造函数的初始化列表中对类对象进行初始化。比如一个直线类Line对象中包含两个Point类对象,对Point对象的创建就必须要放在Line的构造函数的初始化列表中进行。

class Line
{
public:
    Line(int x1, int y1, int x2, int y2)
    : _pt1(x1, y1)
    , _pt2(x2, y2)
    {
    	cout << "Line(int,int,int,int)" << endl;
    }
private:
    Point _pt1;
    Point _pt2;
};

静态数据成员

C++允许使用static(静态存储)修饰数据成员,这样的成员在编译时就被创建并初始化的(与之相比,对象是在运行时被创建的),且其实例只有一个,被所有该类的对象共享。

如下设置一个静态数据成员_pointName

class Point
{
public:
    Point(int ix, int iy, char pointName)
    : _ix(ix)
    , _iy(iy)
    ,_pointName(pointName)//error 不能在这里进行初始化
    {
    }
    
private:
    const int _ix;
    const int _iy;
    static char _pointName;//不存储在对象中,而是在全局/静态区
};

静态数据成员和之前介绍的静态变量一样,当程序执行时,该成员已经存在,一直到程序结束,任何该类对象都可对其进行访问,静态数据成员存储在读写段,即全局/静态区,并不占据对象的存储空间

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类对象时被定义的。这意味着它们不是由类的的构造函数初始化的,一般来说,我们不能在类的内部初始化静态数据成员,必须在类的外部初始化静态数据成员,且不再包含static关键字。

char Point::_pointName = 'A';

特别地,对于头文件和实现文件的形式,如果该文件在头文件Point.h中,会被实现文件Point.cc和测试文件testPoint.cc都执行#include "Point.h"调用,那么会执行两次char Point::_pointName = 'A';,会出现多重定义错误(multiple definition of “Point::_pointName”),因此需要将初始化静态数据成员放在实现文件Point.cc中进行。

特殊成员函数

特殊成员函数研究的就是this指针的问题。

静态成员函数

成员函数也可以定义成静态的,静态成员函数的特点:

  • 静态成员函数内部不能使用非静态的数据成员和非静态的成员函数,只能调用静态数据成员和静态成员函数
  • 非静态成员函数内部可以调用静态数据成员和静态成员函数
  • 静态成员函数如果想要访问非静态的数据成员和成员函数可以使用函数传参(即this指针用对象进行替代)

原因在于静态成员函数的参数列表中不含有隐含的this 指针。

this指针隐藏在非静态成员函数的第一个参数位置。

class Computer
{
public:
    Computer(const char * brand, double price)
    : _brand(new char[strlen(brand) + 1]())
    , _price(price)
    {
    	_totalPrice += _price;
    }

    static void printTotalPrice()
    {
        this;//error 静态成员函数的第一个参数位置没有隐含的this指针
    	cout << "总价:" << _totalPrice << endl;
        cout << "单价:" << _price <<endl;//error 没有this指针,不能正确指向this->_price
    }
    //...
private:
    char * _brand;
    double _price;
    static double _totalPrice;
};
  • 静态成员函数除了使用对象进行调用,还可以直接通过类名加作用域限定符进行调用
int main()
{
    Computer pc1("Huawei MateBook14", 5699);
    pc1.printTotalPrice();//可以用“对象名.静态成员函数”的方式调用
    Computer::printTotalPrice();//通过“类名::静态成员”函数直接调用
    return 0;
}

const成员函数

把const关键字放在函数的参数列表和函数体之间(与之前介绍的const放在函数前修饰返回值不同),称为const成员函数。

  • 只能读取类数据成员,不能修改
  • 只能调用const成员函数,不能调用非const成员函数
class Computer
{
public:
    //...
    void print() const//里面有隐含的双const指针
    {
        this->_price = 100;//error 不能修改指针指向的内容
        this = nullptr;//error 不能修改指针的指向
        cout << "品牌:" << _brand << endl
        cout << "价格:" << _price << endl;
        this = nullptr;
    }
    void print()
    {
        cout << "品牌:" << _brand << endl
        cout << "价格:" << _price << endl;
    }
};

此时的print()print() const实现了函数重载。如何实现的呢?因为print()成员函数中有Computer *const this,即this指针,而print() const成员函数中有的是const Computer *const this,即使用双const修饰的this指针。

const成员函数的目的是不修改数据成员,为了保护数据成员不被修改。

此外,const成员函数与非const成员函数具有如下特点:

  • 非const对象默认调用非const版本的成员函数,const对象默认调用const版本的成员函数
  • 如果没有非const版本的成员函数,那么const对象和非const版本的对象都会调用const版本的成员函数
  • 如果没有const版本的成员函数,会出现错误,const对象不能调用非const版本的成员函数
Computer com("Thinkpad", 5500);
com.print();//默认调用print(),没有的话调用print() const

const Computer com2("mac", 20000);
com2.print();//只能调用print() const,不能调用print()

因此如果只想写一个版本的成员函数,建议写const版本的成员函数。

对象的组织

const对象

类对象也可以声明为const对象。

一般来说,能作用于const对象的成员函数除了构造函数和析构函数,只有const成员函数,因为const对象只能被创建、撤销以及只读访问,改写是不允许的。

const Print pt(1, 2);
pt.print();//调用print() const

指向对象的指针

对象占据一定的内存空间,和普通变量一致。

Point pt(1, 2);
Point *p1 = nullptr;//直接置为nullptr
Point *p2 = &pt;//取地址给指针初始化
Point *p3 = new Point(3, 4);//申请动态内存给指针初始化
Point *p4 = new Point[5];//指针存储的是对象所占内存空间的首地址

p3->print();//合法
(*p3).print();//合法

对象数组

对象数组也有声明、初始化和使用3个步骤。

int arr[3] = {1, 2, 3};

Point pts[2] = {Point(1, 2), Point(3, 4)};
Point pts[] = {Point(1, 2), Point(3, 4)};
Point pts[5] = {Point(1, 2), Point(3, 4)};

//还有如下写法,需要注意,除了去掉Point,还换了大括号{}
Point pts[2] = {{1, 2}, {3, 4}};
Point pts[] = {{1, 2}, {3, 4}};
Point pts[5] = {{1, 2}, {3, 4}};

堆对象

和把一个简单变量创建在动态存储区一样,可以用new和delete表达式为对象分配动态存储区。

使用new表达式为对象数组分配动态空间时,不能显式调用对象的构造函数,因此,对象要么没有定义任何形式的构造函数(由编译器缺省提供),要么显式定义了一个(且只能有一个)所有参数都有缺省值的构造函数。

void test()
{
    Point *pt1 = new Point(11, 12);
    pt1->print();
    delete pt1;
    pt1 = nullptr;
    
    Point * pt2 = new Point[5]();
    delete [] pt2;
}

单例模式的实现

单例模式是23种GOF 模式中最简单的设计模式之一,属于创建型模式,它提供了一种创建对象的方式,确保只有单个对象被创建。这个设计模式主要目的是想在整个系统中只能出现类的一个实例,即一个类只有一个对象。

假设要创建一个单例模式的类Singleton,根据我们已经学过的知识,内存的用户区有如下区域:栈 、堆 、全局/静态、文字常量 、程序代码,其中后面两个Singleton类不会被存放。

如果希望Singleton类只能创建一个对象,那么Singleton类不能放在栈、堆、全局/静态区里。

那如何解决此问题呢?其实现步骤有如下三步:

  1. 将构造函数私有化
  2. 在类中定义一个静态的指向本类型的指针变量_pInstance
  3. 定义一个返回值为类指针的静态成员函数
class Singleton
{
public:
    static Singleton * getInstance()//如果没有static限定符,那需要用“对象.getInstance()”的方式来创建对象,但此时又没有存在的对象用以创建,因此加上static限定符修饰后用“类::getInstance()”来调用静态成员函数
    {
        if(_pInstance == nullptr)//保证只创建一次对象
            _pInstance = new Singleton();//使用了堆区
        return _pInstance;
    }
    
    static void destroy()//保证只销毁一次对象
    {
        if(_pInstance != nullptr)
        {
            delete _pInstance;
        	_pInstance = nullptr;
        }
    }
    
private:
    Singleton()//将构造函数私有化
    {
    	cout << "Singleton()" << endl;
    }
    
    ~Singleton()
    {
        cout << "~Singleton()" << endl;
    }
    
private:
    static Singleton *_pInstance;
};

Singleton *Singleton::_pInstance = nullptr;//保证第一次可以创建,之后不能创建

int main()
{
    Singleton *p1 = Singleton::getInstance();
    Singleton *p2 = Singleton::getInstance();
    assert(p1 == p2);
    
    ps1->destroy();
    ps2->destroy();//此时_pInstance为nullptr,不会销毁对象
    Singleton::destroy();//此时_pInstance为nullptr,不会销毁对象
    
    return 0;
}

new和delete表达式、C++流、缓冲区

new和delete表达式

new表达式工作步骤

使用new表达式时发生的三个步骤:

  1. 调用名为operator new的标准库函数(底层是调用malloc函数),分配足够大的原始的未类型化的内存,以保存指定类型的一个对象
  2. 运行该类型的一个构造函数初始化对象
  3. 返回指向新分配并构造的构造函数对象的指针

delete表达式工作步骤

使用delete表达式时发生的两个步骤:

  1. 调用析构函数,回收对象中数据成员所申请的资源
  2. 调用名为operator delete的标准库函数释放该对象所用的内存

operator new/delete函数的重载

//operator new库函数
void *operator new(size_t);
void *operator new[](size_t);

//operator delete库函数
void operator delete(void *);
void operator delete[](void *);

下面通过例子来说明这两个函数的用法:

class Student
{
public:
    Student(int id, const char *name)
    : _id(id)
    , _name(new char[strlen(name) + 1]())
    {
        cout << "Student()" << endl;
        strcpy(_name, name);
    }
    
    ~Student()
    {
        cout << "~Student()" << endl;
        if _name != nullptr
        {
            delete [] _name;
        	_name = nullptr;
        }
    }
    
    void *operator new(size_t sz)
    {
        cout << "void *operator new(size_t)" << endl;
        void *ret = malloc(sz);
        return ret;
    }
    
    void operator delete(void *pointer)
    {
        cout << "void operator delete(void *)" << endl;
    	free(pointer);
    }
    
    void print() const
    {
        cout << "id:" << _id << ", name:" << _name << endl;
    }
    
private:
    int _id;
    char _name;
};

int main(void)
{
    Student *pstu = new Student(100, "Jackie");
    pstu->print();//相当于(*pstu).print()
    
    delete pstu;
    pstu = nullptr;
    return 0;
}

此时创建的pstu指针在栈上,Student类创建的对象都在堆上,包括_id和_name指针,_name指向的数据“xiaohong”。

屏幕截图 2024-01-17 151249

注意:

  • 对象销毁与析构函数的执行等价吗?

    A:不一定等价。

    对于堆对象而言,执行析构函数只是对象的销毁的一个步骤,回收对象中数据成员所申请的资源,如上图中执行析构函数回收了_name申请的“xiaohong”所占的资源;而对于对象的销毁还要包括堆对象的所用的内存。

    对于栈对象而言,执行析构函数与对象销毁等价。

  • 可以试试成员函数operator new/delete里面是否包含this指针?

    A:不存在this指针。

  • operator new/delete函数放在类外且不加类名与作用域限定符,又会产生什么现象,该如何解释?

    A:会执行两次operator new/delete函数,以operator new函数为例。此时第一次是执行Student *pstu = new Student(100, "Jackie");这一句中的operator new函数进行Student类的创建,第二次是执行Student(int id, const char *name): _id(id), _name(new char[strlen(name) + 1]())这一句的operator new函数进行char类的创建。

    原因是此时的operator new/delete函数放在类外,成为全局的operator new/delete函数,只要是在此命名空间中调用new或delete函数都会调用。而对于放在类内的operator new/delete函数只针对该类的创建和销毁。

    因此,如果定义两个operator new/delete函数,一个放在Student类外,一个放在Student类内。此时第一次执行Student类的创建时会执行类内的operator new函数,第二次执行char类的创建时会执行类外的operator new函数。

要求类只创建栈对象

如果要求一个类只能创建栈对象,其意思是在创建出栈对象的同时,不能创建堆对象。

以Student类型而言,

void test0()
{
    Student s1(101, "John");//ok 可以创建栈对象
    Student *pstu = new Student(101, "Mark");//error 不能创建堆对象
}

只需要将operator new函数放入Student类的private区域。此时构造函数不是私有的,可以调用来创建栈对象。而operator new函数是私有的,不能通过调用operator new函数来创建堆对象。

  • 创建栈对象需要什么条件?

    A:构造函数和析构函数不能为私有

要求类只创建堆对象

如果要求一个类只能创建堆对象,其意思是在创建出堆对象的同时,不能创建栈对象。以Student类型而言,只需要将Student类的析构函数放入private区域。

void test1()
{
    Student s1(101, "John");//error 不可以创建栈对象
    Student *pstu = new Student(101, "Mark");//ok 可以创建堆对象
}

但如果只是将Student类的析构函数放入private区域,会出现一个问题:operator delete函数也需要调用析构函数,导致堆对象也无法被创建。

我们可以定义一个函数void destory()在类内来显式调用析构函数,有

class Student
{
public:
    Student(int id, const char *name) ;
    void *operator new(size_t sz);
    void operator delete(void *pointer);
    void print() const;
    
    void destroy()
    {
        this->~Student;//不能直接写成~Student();
    }
    
private:    
    ~Student()
    {
        cout << "~Student()" << endl;
        //delete this; 如果在这里加上这一句,那么会出现死循环:进入~Student()之后,会执行这一句,随后operator delete函数再次调用~Student(),循环往复,最后造成Segmentation fault。
        if _name != nullptr
        {
            delete [] _name;
        	_name = nullptr;
        }
    }
    
private:
    int _id;
    char _name;
};

int main(void)
{
    Student s1(101, "John");//error 不可以创建栈对象
    Student *pstu = new Student(101, "Mark");
    
    pstu->destroy();
    pstu = nullptr;
    return 0;
}

此时会有输出为

lizi@virtual:CppBaseDay10$./a.out 
void *operator new(size_t )
Student(const char *, int)
name: lili
id: 4321
~Student()

此时不会执行operator delete函数。

优化方法是在destroy函数中可以用operator delete函数对this指针进行操作,此时会先调用析构函数,回收this指针指向的对象中数据成员所申请的资源,再调用名为operator delete函数释放this指针指向的对象所用的内存,如下:

void destroy()
{
    /* this->~Student;//不能直接写成~Student(); */
    delete this;//this即Student *const this
}

综上,要求类只创建堆对象,需要将析构函数设置为私有,另外需要定义一个在类的内部的destroy函数来调用operator delete函数,此时operator delete函数在类内部,可以调用析构函数。

C++输入输出机制

流的概念

C++的 I/O发生在流中,流是字节序列。如果字节流是从设备(如键盘、磁盘驱动器、网络连接等)流向内存,这叫做输入操作。如果字节流是从内存流向设备(如显示屏、打印机、磁盘驱动器、网络连接等)这叫做输出操作

就C++程序而言, I/O操作可以简单地看作是从程序移进或移出字节,程序只需要关心是否正确地输出了字节数据,以及是否正确地输入了要读取字节数据,特定I/O设备的细节对程序员是隐藏的。

常用流类型

  • 标准IO:键盘或显示
  • 文件IO:外存磁盘
  • 串IO:内存,一般以字符数组为存储空间
类名作用头文件
ios_base抽象基类,管理格式化标志和输入/输出异常iostream
ios抽象基类,管理任意流缓冲iostream
istream
ostream
iostream
通用输入流
通用输出流
通用输入输出流
iostream
ifstream
ofstream
fstream
文件输入流
文件输出流
文件输入输出流
fstream
istringstream
ostringstream
stringstream
字符串输入流
字符串输出流
字符串输入输出流
sstream

i-input,o-output,stream-流,f-file,string-字符串。

流类型之间的关系

继承图:

4

越上面越基础。

流的状态 

IO操作与生俱来的一个问题是可能会发生错误,一些错误是可以恢复的,另一些是不可以的。在C++标准库中,用iostate来表示流的状态,不同的编译器iostate的实现可能不一样,不过都有四种状态:goodbit、badbit、eofbit、failbit。

  • goodbit被置位表示流未发生错误。只有流的状态时goodbit的时候,才能进行正常的操作,可以使用good函数进行查看。
  • badbit表示发生系统级的错误,如不可恢复的读写错误。通常情况下一旦badbit被置位,流就无法再使用了。可以通过bad函数进行查看。
  • eofbit表示文件指针到达了文件末尾。可以使用eof函数进行查看。
  • failbit表示发生可恢复的错误,如期望读取一个数值,却读出一个字符等错误。这种问题通常是可以修改的,流还可以继续使用。可以使用fail函数进行查看。

当到达文件的结束位置时,eofbit和failbit都会被置位。

enum _Ios_Iostate
{
    _S_goodbit         = 0,//默认为0
    _S_badbit          = 1L << 0,//默认为1(long类型)
    _S_eofbit          = 1L << 1,//默认为2(long类型)
    _S_failbit         = 1L << 2,//默认为4(long类型)
    ...
}

管理流的状态

C++标准库中有一系列成员函数来查询或操作这些状态。

bool bad() const; //若流的badbit置位,则返回true
bool fail() const; //若流的failbit或badbit置位,则返回true
bool eof() const; //若流的eofbit置位,则返回true
bool good() const; //若流处于有效状态,则返回true

iostate rdstate() const; //获取流的状态
void setstate(iostate state); //设置流的状态

//clear的无参版本会复位所有错误标志位*(重置流的状态)
void clear(std::ios_base::iostate state = std::ios_base::goodbit);

例:

#include <iostream>
#include <string>
#include <limits>

using std::cout;
using std::endl;
using std::cin;
using std::string;

void printStreamStatus()
{
    cout << "cin.badbit = " << cin.bad() << endl;
    cout << "cin.failbit = " << cin.fail() << endl;
    cout << "cin.eofbit = " << cin.eof() << endl;
    cout << "cin.goodbit = " << cin.good() << endl;
}
void test()
{
    int number = 0;
    printStreamStatus();
    cin >> number;
    printStreamStatus();

    cout << "number = " << number << endl;
    cin.clear();//重置流的状态
    cin.ignore(1024, '\n'); //读取流的前1024字符,遇到'\n'就清空缓冲区
	cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');//读取流的最大可能值,遇到'\n'就清空缓冲区
    //std::numeric_limits<int>::max()即表示int的最大可能值
    printStreamStatus();

    string s1;
    cin >> s1;
    cout << "s1 = " << s1 << endl;
}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

流的通用操作

//----以下输入流操作----
int_type get();//读取一个字符
istream & get(char_type & ch);
//读取一行数据
istream & getline(char_type * s, std::streamsize count, char_type delim
='\n');
//读取count个字节的数据
istream & read(char_type * s, std::streamsize count);
//最多获取count个字节,返回值为实际获取的字节数
std::streamsize readsome(char_type * s, std::streamsize count);
//读取到前count个字符或在读这count个字符进程中遇到delim字符(终止符)就停止,并把读取的这些东西丢掉
istream & ignore(std::streamsize count = 1, int_type delim = Traits::eof());
//查看输入流中的下一个字符, 但是并不将该字符从输入流中取走
//不会跳过输入流中的空格、回车符; 在输入流已经结束的情况下,返回 EOF。
int_type peek();
//获取当前流中游标所在的位置
pos_type tellg();
//偏移游标的位置
basic_istream & seekg(pos_type pos);
basic_istream & seekg(off_type off, std::ios::seekdir dir);
//----以下为输出流操作----
//往输出流中写入一个字符
ostream & put(char_type ch);
//往输出流中写入count个字符
ostream & write(const char_type * s, std::streamsize count);
//获取当前流中游标所在的位置
pos_type tellp();
//刷新缓冲区
ostream & flush();
//偏移游标的位置
ostream & seekp(pos_type pos);
ostream & seekp(off_type off, std::ios_base::seekdir dir);

缓冲区

缓冲区又称为缓存,它是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。

缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区输出缓冲区

引入缓冲区的原因

缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速设备能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。

缓冲区的类型

  • 全缓冲:当填满标准I/O缓存后才进行实际I/O操作。全缓冲的典型代表是对磁盘文件的读写。
  • 行缓冲:当在输入和输出中遇到换行符时,执行真正的I/O操作。这时,我们输入的字符先存放在缓冲区,等按下回车键换行时才进行实际的I/O操作。典型代表是键盘输入数据。
  • 不带缓冲:不进行缓冲,标准出错情况cerr/stderr是典型代表,这使得出错信息可以直接尽快地显示出来。

刷新缓冲区的方式★

  • 程序正常结束的时候,会刷新缓冲区

    void test(){
        for(size_t idx = 0; idx < 1024; ++idx){
            cout << 'a';
        }
        sleep(3);
    }
    

    此时1024个a会等到函数正常结束的时候(会等待3秒)刷新缓冲区进行打印。

  • 缓冲区满的时候,也会刷新缓冲区

    void test(){
        for(size_t idx = 0; idx < 1024; ++idx){
            cout << 'a';
        }
        cout << 'b';
        sleep(3);
    }
    

    此时1024个a先打印出来,过了3秒后b再打印出来。说明此时1024个a把缓冲区占满,刷新缓冲区;而b是等到程序正常结束时输出。

    ubuntu18.04版本的缓冲区大小是1024B。

  • 可以人为手动刷新缓冲区,比如:endl、flush

    void test(){
        for(size_t idx = 0; idx < 1024; ++idx){
            cout << 'a';
        }
        cout << 'b' << endl;
        cout << 'c' << flush;
        cout << 'd' << ends;
        sleep(3);
    }
    

    此时1024个a、b和c被打印出来,过了3秒后d被打印出来。说明endl和flush有刷新缓冲区的功能。

    endl刷新并且换行

    flush刷新但不换行

    ends不刷新也不换行

  • 使用unitbuf操作符设置流的内部状态

  • 输出流和输入流相关联,此时在读取输入流时将刷新其关联的输出流的输出缓冲区

源码理解

函数指针

根据目录/usr/include/c++/7/下的ostream文件,可以看到 class basic_ostream : virtual public basic_ios<_CharT, _Traits>,basic_ostream这个类的范围在57行-416行。

举个例子,107行-114行有如下代码:

__ostream_type& operator<<(__ostream_type& (*__pf)(__ostream_type&))//成员函数
{
    // _GLIBCXX_RESOLVE_LIB_DEFECTS
    // DR 60. What is a formatted input function?
    // The inserters for manipulators are *not* formatted output functions.
    return __pf(*this);
}

可以理解一下,类似于

int &func(int &(*pf)(int &))
{
    
}

如果还是看不懂,可以再化简类似于

int func2(int (*pf)(int))//func2的参数是一个函数指针
{
    
}

其中的int (*pf)(int)是一个函数指针,因此其实__ostream_type& (*__pf)(__ostream_type&)也是一个函数指针,表示这个函数指针(*__pf)可以指向参数和返回类型都是__ostream_type&的函数。

此时可以如果有一个函数形为int func(int)时,如

int func3(int x)
{
    return x;
}

可以将其作为fun2函数的参数

int main()
{
    fun2(&fun3);
    fun2(fun3);//都可以,因为函数名的地址就是函数的入口地址
}

进一步理解的话,有如下代码:

int func(int (*pf)(int, int))
{
    cout  << "func = " << pf(2, 3) << endl;
}

int add(int x, int y)
{
    return x + y;
}

int mul(int x, int y)
{
    return x * y;
}

void main()
{
    func(&add);//func = 5
    func(&mul);//func = 6
}
endl、flush和ends的源码理解

endl、flush和ends都是函数。

endl的源码与函数指针★

107行-114行的代码有一个函数如下:

__ostream_type& operator<<(__ostream_type& (*__pf)(__ostream_type&))//成员函数
{
    // _GLIBCXX_RESOLVE_LIB_DEFECTS
    // DR 60. What is a formatted input function?
    // The inserters for manipulators are *not* formatted output functions.
    return __pf(*this);
}

此外有endl的源码

template<typename _CharT, typename _Traits>
    inline basic_ostream<_CharT, _Traits>&
    endl(basic_ostream<_CharT, _Traits>& __os)
    { return flush(__os.put(__os.widen('\n'))); }

忽视template<>内容进行简化,有

inline basic_ostream&
endl(basic_ostream& __os)
{
    return flush(__os.put(__os.widen('\n'))); 
}

endl函数中调用了flush(__os.put(__widen('\n')));函数,表示刷新并换行。

此时endl函数的参数和返回类型都是__ostream_type&,说明上面的函数__ostream_type& operator<<(__ostream_type& (*__pf)(__ostream_type&))的参数中的函数指针可以指向endl函数,而此函数在basic_ostream类中,所以它是一个成员函数,因此应该使用“对象.成员函数”的方式调用:

cout.operator<<(endl);

相似地,可以在517-547行找到如下源码:

template<typename _CharT, typename _Traits>
    inline basic_ostream<_CharT, _Traits>&
    operator<<(basic_ostream<_CharT, _Traits>& __out, const _CharT* __s)
    {
      if (!__s)
    __out.setstate(ios_base::badbit);
      else
    __ostream_insert(__out, __s,
             static_cast<streamsize>(_Traits::length(__s)));
      return __out;
    }

观察其中的函数头,可以看到basic_ostream operator<<(basic_ostream, const _CharT* __s)不在basic_ostream类(57行-416行)中,函数表示前面可以传入一个参数为basic_ostream类对象(包括cout),后面可以传入一个C风格字符串。那么我们可以这样调用这个函数:

operator<<(cout, "hello");

此函数的返回类型是一个basic_ostream类的对象,所以这个返回结果作为参数被basic_ostream opertor<<(basic_ostream, const _CharT* __s)调用,返回的结果又可以调用成员函数。可以有如下形式:

operator<<(operator<<(cout, "hello"), ", world!").operator(endl);

上面的函数也就是相当于

cout << "hello" << ", world!" << endl;
flush的源码
template<typename _CharT, typename _Traits>
    inline basic_ostream<_CharT, _Traits>&
    flush(basic_ostream<_CharT, _Traits>& __os)
    { return __os.flush(); }

调用了__os类中的成员函数flush,可以看到flush只有刷新的作用。

ends的源码
template<typename _CharT, typename _Traits>
    inline basic_ostream<_CharT, _Traits>&
    ends(basic_ostream<_CharT, _Traits>& __os)
    { return __os.put(_CharT()); }

没有__os.flush();,因此不能刷新。而是调用了一个函数__os.put(_CharT());,表示输出了一个‘\0’字符,在Linux系统中不会有任何输出,在Windows系统中会输出一个空格。

标准IO、文件IO和串IO与日志系统

C++标准IO

标准输入流

istream类定义了1个输入流对象,即cin, 代表的是标准输入,它从标准输入设备(键盘)获取数据,程序中的变量通过流提取符»从流中提取数据。

流提取符»从流中提取数据时通常跳过输入流中的空格、tab键、换行符等空白字符。只有在输入完数据再按回车键后,该行数据才被送入键盘缓冲区,形成输入流,提取运算符»才能从中提取数据。

下面来看一个例子,可以一直循环输入

void test2()
{
    //循环操作
    int number = 0;
    //逗号表达式
    //ctrl+ d可以退出
    while(cin >> number, !cin.eof())//逗号表达式:最后一个逗号的下一块表达式的值才是逗号表达式的值
    {
        if(cin.bad())//流的状态是bad
        {
            cout << "stream is bad." << endl;
            return;
        }
        else if(cin.fail())//流的状态是fail
        {
            cin.clear();//重置流的状态
            cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');//清空缓存区
            cout << "please input int data." << endl;
        }
        else 
        {
            cout << "number = " << number << endl;
        }
    }
}

标准输出流

ostream类定义了3个全局输出流对象,即cout,cerr,clog。

  • cout是标准输出,将数据输出到终端,它与标准C输出stdout关联。
  • cerr是标准错误流(非缓冲)
  • clog也是标准错误流(带缓冲)

注意:与Linux相似,在C语言中,标准输入、标准输出和标准错误分别用0, 1, 2文件描述符代表。

文件IO★

C++对文件进行操作的流类型有三个: ifstream(文件输入流), ofstream(文件输出流), fstream(文件输入输出流),他们的构造函数形式都很类似。

explicit关键字

以Point类为例:

class Point
{
    int _ix;//默认为private
    int _iy;
};

Point pt = 10;

此时也是可以调用的,本质上是不同类之间的转换。首先将int类型10转化为Point(10, 0),随后调用拷贝构造函数对pt进行初始化。这称为隐式转换

explicit关键字可以防止隐式转换。如果对构造函数Point::Point前面加上explicit关键字,再次调用Point pt = 10;,会出现错误error: conversion from ‘int’ to non-scalar type ‘Point’ requested。

文件输入流

构造函数

依据参考文档ifstream类中的构造函数std::basic_ifstream<CharT,Traits>::basic_ifstream有:

< cpp‎ | io‎ | basic ifstream

std::basic_ifstream<CharT,Traits>::basic_ifstream
basic_ifstream();(1)
explicit basic_ifstream( const char* filename, std::ios_base::openmode mode = ios_base::in );(2)
explicit basic_ifstream( const std::filesystem::path::value_type* filename, std::ios_base::openmode mode = ios_base::in );(3)(C++17 起)
explicit basic_ifstream( const std::string& filename, std::ios_base::$openmode$ mode = ios_base::in );(4)(C++11 起)
template< class T > explicit basic_ifstream( const T& filename, std::ios_base::openmode mode = ios_base::in );(5)(C++17 起)
basic_ifstream( basic_ifstream&& other );(6)(C++11 起)
basic_ifstream( const basic_ifstream& rhs) = delete;(7)(C++11 起)

构造新的文件流。

  1. 默认构造函数:构造不关联到文件的流:默认构造 std::basic_filebuf 并构造拥有指向此默认构造的 std::basic_filebuf 成员的基类。

2-3) 首先,进行同默认构造函数的步骤,然后通过调用 rdbuf()->open(filename, mode | std::ios_base::in)(该调用效果上的细节见 std::basic_filebuf::open )关联流与文件。若 open() 调用返回空指针,则设置 setstate(failbit) 。仅若 std::filesystem::path::value_type 非 char 才提供重载 (3) 。 (C++17 起)

4-5) 同 basic_ifstream(filename.c_str(), mode) 。 (5) 仅若 Tstd::filesystem::path 才参与重载决议。 (C++17 起)

  1. 移动构造函数:首先,从 other 移动构造基类(这不影响 rdbuf() 指针),然后移动构造 std::basic_filebuf 成员,再调用 this->set_rdbuf() 安装新的 basic_filebuf 为基类中的 rdbuf() 指针。

  2. 复制构造函数被删除:此类不可复制。

复制构造函数即拷贝构造函数。

综上,忽视C++17及以上版本的函数,有以下使用方式:

basic_ifstream();//1. 无参构造函数

explicit basic_ifstream( const char* filename,
                        std::ios_base::openmode mode = ios_base::in );//2. 可以传入C风格字符串的名字

explicit basic_ifstream( const std::string& filename,
                        std::ios_base::openmode mode = ios_base::in );//4. 可以传入C++风格字符串的名字

basic_ifstream( basic_ifstream&& other );//6. 可以传入右值引用

basic_ifstream( const basic_ifstream& rhs) = delete;//7. 删除拷贝构造函数,保证此类(basic_ifstream)不可以复制

例子:

void test()
{
    ifstream ifs("lizi.txt");//文件不存在时会打开失败
    if(!ifs.good())
    {
        cerr << "ifstream is not good" << endl;//使用cerr输出时,保证用标准输出>>时不会显示,用标准错误输出2>>时显示
        return;
	}
    string word;
    while(ifs >> word)//ifs != true
    {
        cout << word << endl;//对于文件输出流来说,默认以空格为分隔符
    }
    cout endl;
    
    ifs.close();
}

int main()
{
    test();
    return 0;
}

此时的输出形式很不美观。因为是按照空格作为分隔符。

getline函数

< cpp‎ | string‎ | basic string

定义于头文件 <string>
template< class CharT, class Traits, class Allocator > std::basic_istream<CharT,Traits>& getline( std::basic_istream<CharT,Traits>& input, std::basic_string<CharT,Traits,Allocator>& str, CharT delim );(1)
template< class CharT, class Traits, class Allocator > std::basic_istream<CharT,Traits>& getline( std::basic_istream<CharT,Traits>&& input, std::basic_string<CharT,Traits,Allocator>& str, CharT delim );(1)(C++11 起)
template< class CharT, class Traits, class Allocator > std::basic_istream<CharT,Traits>& getline( std::basic_istream<CharT,Traits>& input, std::basic_string<CharT,Traits,Allocator>& str );(2)
template< class CharT, class Traits, class Allocator > std::basic_istream<CharT,Traits>& getline( std::basic_istream<CharT,Traits>&& input, std::basic_string<CharT,Traits,Allocator>& str );(2)(C++11 起)

那么我们可以进行如下优化:

void test()
{
    ifstream ifs("lizi.txt");
    if(!ifs.good())
    {
        cerr << "ifstream is not good" << endl;
        return;
	}
    string line;
    while(getline(ifs, line))//此时一行一行读
    {
        cout << line << endl;
    }
    cout endl;
    
    ifs.close();
}

int main()
{
    test();
    return 0;
}

此时的输出按照行来读取,比较舒适。

但是目前还是有一个问题,不能指定行号来读取,应该怎么办呢?可以将line作为一个数组来存取。

void test()
{
    ifstream ifs("lizi.txt");
    if(!ifs.good())
    {
        cerr << "ifstream is not good" << endl;
        return;
	}
    string line[100];
    size_t idx = 0;
    while(getline(ifs, line[idx]))
    {
        cout << line[idx] << endl;
        ++idx;
    }
    cout << line[9] < endl;//可以读取文件的第10行
    
    ifs.close();
}

int main()
{
    test();
    return 0;
}

我们这里设定line数组可以存100个数据元素,但是如果超过了100行怎么办呢?可以使用vector。

3. vector容器

vector 是 C++ 标准库中的一个动态数组容器,它可以自动管理内存大小,可以在运行时根据需要动态增长或缩小。它是一个非常常用且强大的容器,用于存储一系列元素。需要#include <vector>using std::vector;进行调用。

vector的扩容原理★
void test()
{
    vector<int> number;
    cout << "number.size() = " << number.size() << endl;
    cout << "number.capacity() = " << number.capacity() << endl;
    
    cout << endl;
    number.push_back(1);
    cout << "number.size() = " << number.size() << endl;
    cout << "number.capacity() = " << number.capacity() << endl;
    
    cout << endl;
    number.push_back(2);
    cout << "number.size() = " << number.size() << endl;
    cout << "number.capacity() = " << number.capacity() << endl;
    
    number.push_back(3);
    cout << "number.size() = " << number.size() << endl;
    cout << "number.capacity() = " << number.capacity() << endl;
    
    cout << endl;
    number.push_back(4);
    cout << "number.size() = " << number.size() << endl;
    cout << "number.capacity() = " << number.capacity() << endl;
    
    cout << endl;
    number.push_back(5);
    cout << "number.size() = " << number.size() << endl;
    cout << "number.capacity() = " << number.capacity() << endl;
}

此时会有输出为

lizi@virtual:CppBaseDay11$./a.out 
number.size() = 0
number.capacity() = 0

number.size() = 1
number.capacity() = 1

number.size() = 2
number.capacity() = 2

number.size() = 3
number.capacity() = 4

number.size() = 4
number.capacity() = 4

number.size() = 5
number.capacity() = 8

当元素的个数size()与容量的大小capacity()相同的时候,如果还想继续插入元素,就会按照2*size()进行扩容,并且将原来的空间中的元素拷贝到新的空间中,再将原来的空间回收(新的空间是在新的位置进行一次申请)。

使用vector进行优化
void test()
{
    ifstream ifs("lizi.txt");
    if(!ifs.good())
    {
        cerr << "ifstream is not good" << endl;
        return;
	}
    
    vector<string> vec;
    string line;
    
    while(getline(ifs, line))
    {
        vec.push_back(line);
    }
    
    for(size_t idx = 0; idx != vec.size(); ++idx)
    {
        cout << vec[idx] << endl;
    }
    
    cout << "vec[9] = " << vec[9] << endl;
    cout << "vec[36] = " << vec[36] << endl;
    
    ifs.close();
}

int main()
{
    test();
    return 0;
}

此时便不用担心内存越界的问题。

文件的输出流

ifstream与ofstream的操作基本一致。用一个例子:

#include <iostream>
#include <fstream>
#include <string>

using std::cout;
using std::endl;
using std::cerr;
using std::ofstream;
using std::ifstream;
using std::string;

void test2()
{
    //想将lizi.txt中的内容写入到test.txt中
    ifstream ifs("lizi.txt");
    if(!ifs.good())
    {
        cerr << "ifstream is not good" << endl;
        return;
    }

    //对于文件输出流而言,当文件不存在的时候,就创建文件;当文件存在的时候,就会清空文件中的内容★
    ofstream ofs("test.txt");
    if(!ofs.good())
    {
        cerr << "ofstream is not good" << endl;
        return;
    }

    string line;
    while(getline(ifs, line))//用ifs将lizi.txt的内容按行读入line中
    {
        ofs << line << endl;//用ofs将line中的内容读入test.txt中(其实就是把cout标准输出流换成ofs文件输出流)
    }

    ifs.close();
    ofs.close();
}
int main(int argc, char *argv[])
{
    test2();
    return 0;
}

文件的输入输出流

文件流对象的偏移

文件的输入输出流一定要注意读写指针的位置。如果我们有如下代码:

#include <iostream>
#include <fstream>
#include <string>

using std::cout;
using std::endl;
using std::cerr;
using std::cin;
using std::fstream;
using std::string;

void test()
{
    //对于文件输入输出流而言,当文件不存在的时候,就打开失败;当文件存在的时候,才能进行打开成功
    string fileName("lizi.txt");
    fstream fs(fileName);
    if(!fs.good())
    {
        cerr << "fstream is not good" << endl;
        return;
    }

    //通过键盘作为输入,输入五个int类型的数据,将数据写入到文件中;再将文件中的内容进行输出,打印到屏幕上
    int number = 0;
    for(size_t idx = 0; idx < 5; ++idx)
    {
        cin >> number;//输入1,2,3,4,5
        fs << number << " ";//输出1,2,3,4,5
        cout << endl;
    }
    
    for(size_t idx = 0; idx < 5; ++idx)
    {
        cout << "fs.badbit = " << fs.bad() << endl;
        cout << "fs.failbit = " << fs.fail() << endl;
        cout << "fs.eofbit = " << fs.eof() << endl;
        cout << "fs.goodbit = " << fs.good() << endl; 
        cin >> number;//输入1,2,3,4,5
        fs << number << endl;//输出5,5,5,5,5
    }

    fs.close();
}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

输入1,2,3,4,5,此时有输出为

1 2 3 4 5
f.badbit = 0
fs.failbit = 0
fs.eofbit = 0
fs.goodbit = 1
5
f.badbit = 0
fs.failbit = 1
fs.eofbit = 1
fs.goodbit = 0
5
f.badbit = 0
fs.failbit = 1
fs.eofbit = 1
fs.goodbit = 0
...

eofbit和failbit均置为1表示文件已到达末尾,此时会发生错误。

一定要注意文件流对象有没有偏移到文件的尾部,如果偏移了,那么一定要对文件流对象进行重新置位(seekp/seekg函数)。那么我们应该

#include <iostream>
#include <fstream>
#include <string>

using std::cout;
using std::endl;
using std::cerr;
using std::cin;
using std::fstream;
using std::string;

void test()
{
    string fileName("lizi.txt");
    fstream fs(fileName);
    if(!fs.good())
    {
        cerr << "fstream is not good" << endl;
        return;
    }

    int number = 0;
    for(size_t idx = 0; idx < 5; ++idx)
    {
        cin >> number;
        fs << number << " ";
    }

    //查看文件的长度:tellg/tellp,g表示get,p表示put
    size_t pos = fs.tellg();
    cout << "fs.tellg() = " << pos << endl;
    
    //以下3种等价
    fs.seekp(0);//绝对位置为0
    fs.seekp(0, std::ios::beg);//相对于头部而言,位置为0
    fs.seekp(-pos, std::ios::end);//相对于尾部而言,位置为-pos
    for(size_t idx = 0; idx < 5; ++idx)
    {
        cout << "fs.failbit = " << fs.fail() << endl;
        cout << "fs.eofbit = " << fs.eof() << endl;
        cout << "fs.goodbit = " << fs.good() << endl;
        fs >> number;
        cout << number << " ";
    }

    fs.close();
}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

seekg/seekp用于文件流对象的偏移,tellg/tellp用于查看文件的长度。

对于文件指针的偏移操作涉及三个值:

  • 文件开头:ios_base::beg
  • 指针当前位置:ios_base::cur
  • 文件结尾位置:ios_base::end
文件打开模式

根据不同的情况,对文件的读写操作,可以采用不同的文件打开模式。文件模式在GNU GCC7.4源码实现中,是用一个叫做openmode的枚举类型定义的,它位于ios_base类中。文件模式一共有六种,它们分别是:

  • in: 输入,文件将允许做读操作;如果文件不存在,打开失败
  • out: 输出,文件将允许做写操作;如果文件不存在,则直接创建一个
  • ate: 输入追加,读操作始终发生在文件的末尾
  • app: 输出追加,写入将始终发生在文件的末尾
  • trunc: 截断,如果打开的文件存在,其内容将被丢弃,其大小被截断为零
  • binary: 二进制,读取或写入文件的数据为二进制形式。复制文件时应该使用此模式,不会发生任何隐藏的转换。
#include <iostream>
#include <fstream>
#include <string>

using std::cout;
using std::endl;
using std::cerr;
using std::ifstream;
using std::ofstream;
using std::string;

void test()
{
    ifstream ifs("test.txt", std::ios::in | std::ios::ate);
    if(!ifs)//相当于if(!ifs.good())
    {
        cerr << "ifstream is not good" << endl;
        return;
    }

    cout << "pos = " << ifs.tellg() << endl;//获取读写指针的位置,此时为ate模式,在文件的尾部
    ifs.seekg(0);//将指针绝对位置设置为0

    string line;
    while(getline(ifs, line))
    {
        cout << line << endl;
    }

    ifs.close();
}

void test2()
{
    ofstream ofs("test.txt", std::ios::out | std::ios::app);
    if(!ofs)//if(!ofs.good())
    {
        cerr << "ofstream is not good" << endl;
        return;
    }

    cout << "pos = " << ofs.tellp() << endl;//此时为app模式,pos在打开文件的末尾
    ofs << "hello,world" << endl;
    ofs << "hello,world" << endl;
    ofs << "hello,world" << endl;
    cout << "pos = " << ofs.tellp() << endl;//pos的位置向后了36("hello,world\n"共12个字符,有三个)

    ofs.close();
}

int main(int argc, char *argv[])
{
    test();
    test2();
    return 0;
}

串IO

字符串流是以内存中的字符串类对象或者字符数组为输入输出对象的数据流,也即是将数据输出到字符串流对象或者从字符串流对象读入数据,也称之为内存流。

构造函数

C++对字符串进行操作的流类型有三个:istringstream(字符串输入流),ostringstream(字符串输出流), stringstream(字符串输入输出流),他们的构造函数形式都很类似。

以下为ostringstream的构造函数为例(忽视C++17以上的版本的函数):

< cpp‎ | io‎ | basic ostringstream

std::basic_ostringstream<CharT,Traits,Allocator>::basic_ostringstream
basic_ostringstream() : basic_ostringstream(std::ios_base::out) { }(1)(C++11 起)
explicit basic_ostringstream( std::ios_base::openmode mode = std::ios_base::out );(2)(C++11 前)
explicit basic_ostringstream( std::ios_base::openmode mode );
explicit basic_ostringstream( const std::basic_string<CharT,Traits,Allocator>& str, std::ios_base::openmode mode = std::ios_base::out );(3)(C++11 起)
basic_ostringstream( basic_ostringstream&& other );
  1. 默认构造函数。以默认打开模式构造新的底层字符串设备。

  2. 构造新的底层字符串设备。以 basic_stringbuf<Char,Traits,Allocator>(mode | std::ios_base::out) 构造底层 basic_stringbuf 对象。

  3. str 的副本为底层字符串设备的初始内容。以 basic_stringbuf<Char,Traits,Allocator>(str, mode | std::ios_base::out) 构造底层 basic_stringbuf 对象。

  4. 移动构造函数。用移动语义,构造拥有 other 的状态的字符串流。

其中的1)是委托构造函数。

字符串的输出流

如果希望将数字转化为字符串,可以利用ostringstream类,即字符串的输出流:

string intToString(int number)
{
    ostringstream oss;
    oss << number;
    
    return oss.str();
}

字符串的输入输出流

#include <iostream>
#include <sstream>
#include <string>

using std::cout;
using std::endl;
using std::stringstream;
using std::string;

void test()
{
   int number1 = 10;
   int number2 = 20;
   stringstream ss;
   //字符串输入输出流的输出功能
   ss << "number1= " << number1 
      << ",number2= " << number2 << endl;
   cout << ss.str();

   string key;
   int value;
   //字符串输入输出流的输入功能
   while(ss >> key >> value)//此时会将“number1=”输入到key中,以空格为分隔符,再将“10”输入到value(会进行隐式转换,由String到int)里,然后输出。再次进入循环,将“,number2=”输入到key中,以空格为分隔符,再将“20”输入到value里。
   {
       cout << key << endl;
       cout << value << endl;
   }
}

int main(int argc, char *argv[])
{
   test();
   return 0;
}

此时编译后的结果为

number1= 10,number2= 20
number1=
10
,number2=
20

字符串的输入流

相似地,可以利用字符串的输入流默认按照空格进行划分的特性来对文件进行处理。假设有一个文件config.txt内容为

ip 192.168.0.1
port 8888
pwd /home/lizi/teach

有如下代码

void readConf(const string &filename)
{
    ifstream ifs(filename);
    if(!ifs)
    {
        cerr << "ifstream is not good" << endl;
        return;
    }
    
    string key, value;
    while(ifs >> key >> value)
    {
        cout << "key = " << key << ", value = " << value << endl;
    }

    ifs.close();
}

可以将前面的属性与后面的值进行分离。但是此时ifs读取遇到空格就停止,要6次磁盘IO,分别传给key3次,value3次,效率比较低。

void readConf(const string &filename)
{
    ifstream ifs(filename);
    if(!ifs)
    {
        cerr << "ifstream is not good" << endl;
        return;
    }
    
    while(getline(ifs, line))
    {
        istringstream iss(line);
        string key, value;
        iss >> key >> value;
        cout << "key = " << key << ", value = " << value << endl;
    }
    ifs.close();
}

使用字符串的输入流进行优化,istringstream类在内存中操作。此时按照行来读取,只需要进行磁盘IO三次。自然地,如果直接读取整个文件到内存中进行操作,那就只需要进行磁盘IO一次。

日志系统

参考:日志系统设计 -CSDN博客

日志系统的重要性

日志系统的主要解决的问题是记录系统的运行轨迹,在这个基础上,进行跟踪分析 错误,审计系统运行流程。在系统运行期间,是很难step by step的,所以只能根据系统的运行轨迹来推断错误出现的位置,这也往往是唯一的资料,特别是在高可靠性的情况下。

从更大方面的范围来说,日志系统是运营维护的范畴。但小的方面来说,这是必须 的调试的手段。从多年的开发经验来看,日志系统是必须被我们重视的。

日志系统的设计思路

日志库的设计,一般而言要抓住最核心的一条,就是日志从产生到到达最终目的地 期间的处理流程。一般而言,为了设计一个灵活可扩展,可配置的日志库,主要将 日志库分为4个部分去设计,分别是:记录器、过滤器、格式化器、输出器四部分。

  • 记录器:负责产生日志记录的原始信息,比如(原始信息,日志等级,时间,记录的位置)等等信息。
  • 过滤器:负责按指定的过滤条件过滤掉我们不需要的日志(比如按日志等级过滤)。
  • 格式化器:负责对原始日志信息按照我们想要的格式去格式化。
  • 输出器:负责将将要进行记录的日志(一般经过过滤器及格式化器的处理后)记录到日志目的地(例如:输出到文件中)

log4cpp日志系统

在哪里学习

可以在GitHub中的fffaraz/awesome-cpp)中的readme文件中找到logging,下面由log4cpp日志系统的主页。其中的API Documentation栏可以查看log4cpp的API文档。

安装过程
//将log4cpp-1.1.1.tar.gz下载到linux的家目录,也就是~目录

//执行下面的命令进行安装
$ tar zxvf log4cpp-1.1.1.tar.gz 
$ cd ~/log4cpp/
$ ./configure
$ make
$ make check
$ sudo make install
    
//安装完成之后,可以在对应路径下看头文件与实现文件
默认lib库路径是 : /usr/local/lib/
默认头文件的位置: /usr/local/include/log4cpp  
    
//添加库的路径,在ld.so.conf下面添加 /usr/local/lib/即可
$ sudo vim /etc/ld.so.conf

//更新缓存
$ sudo ldconfig
    
//查看缓存更新的情况
$ ll /etc/*cache*

//如何编译文件?
$ g++ xxx.cpp -llog4cpp -lpthread
使用方法★

日志库分为4个部分去设计,分别是:记录器、过滤器、格式化器、输出器四部分。log4cpp日志库的记录器为Category,过滤器为Priority,格式化器为Layout,输出器为Appender。

  • 日志记录器Category
  • 日志的目的地OstreamAppender/FileAppender/RollingFileAppender
  • 日志的格式BasicLayout/SimpleLayout/PatternLayout
  • 日志的优先级Priority

log4cpp的头文件在log4cpp目录的include/log4cpp下,实现文件在src下,测试文件在test下,可以查看源码进行学习。

使用方式参考log4cpp 相关知识讲解.pdf(TODO:原文件缺失,待补充)和Xmind。

例子

可以建立一个日志记录器Category,添加一个或多个日志目的地Appender,Appender需要设置一个或多个日志格式Layout,并且使用日志记录器设置优先级Priority。

#include <iostream>
#include <log4cpp/BasicLayout.hh>
#include <log4cpp/SimpleLayout.hh>
#include <log4cpp/PatternLayout.hh>
#include <log4cpp/OstreamAppender.hh>
#include <log4cpp/FileAppender.hh>
#include <log4cpp/RollingFileAppender.hh>
#include <log4cpp/Category.hh>
#include<log4cpp/Priority.hh>

using std::cout;
using std::endl;
using namespace log4cpp;

void test()
{
   //日志格式
   PatternLayout *ppl = new PatternLayout();
   ppl->setConversionPattern("%d [%p] %c: %m%n");

   PatternLayout *ppl2 = new PatternLayout();
   ppl2->setConversionPattern("%d [%p] %c: %m%n");
   
   //日志的目的地
   OstreamAppender *poa =  new OstreamAppender("2", &cout);//Appender名字为2,输出到一个输出流为cout进行标准输出
   poa->setLayout(ppl);//以日志格式中设置的pp1格式输出

   FileAppender *pfl = new FileAppender("file", "wd.txt");//Appender名字为file,输出到一个文件wd.txt中
   pfl->setLayout(ppl2);//以日志格式中设置的pp2格式输出

   //日志记录器
   Category &root = Category::getRoot().getInstance("cat");//getRoot()得到根Category,getInstance()得到子Category
   root.addAppender(poa);//设置或添加appender
   root.addAppender(pfl);

   //日志的过滤器
   root.setPriority(Priority::ERROR);//此时下面的notice和debug两个类型的日志会被过滤

   size_t idx = 10000;
   while(idx > 0)
   {
       //取值越小,优先级越高
       root.emerg("This is an emerg message");//0
       root.fatal("This is an fatal message");//0
       root.alert("This is an alert essage");//100
       root.crit("This is an crit message");//200
       root.error("This is an error message");//300
       root.notice("This is an notice message");//500
       root.debug("This is an debug message");//700
       --idx;
   }

   Category::shutdown(); //回收,new的CAtegory、Appender和Layout都不要手动释放;此外一个layout不要附着到多个appender上,一个appender不要加入多个Category,否则会出错
}

int main(int argc, char *argv[])
{
   test();
   return 0;
}

可以进行编译

g++ testLog4cpp.cc -llog4cpp -lpthread

如果使用FileAppender,循环次数很多时文件会很大;因此应该删除老的日志文件,并且应该保存到不同的文件中,此时应该使用RollingFileAppender解决。

void test2()
{
    //日志的格式
    PatternLayout *ppl = new PatternLayout();//建议使用PatternLayout,使用类似printf的方式打印。
    ppl->setConversionPattern("%d [%p] %c: %m%n");

    PatternLayout *ppl2 = new PatternLayout();
    ppl2->setConversionPattern("%d [%p] %c: %m%n");
    
    //日志的目的地
    OstreamAppender *poa =  new OstreamAppender("2", &cout);
    poa->setLayout(ppl)

    RollingFileAppender *prfl = new RollingFileAppender("file", "rollwxb.log", 5 * 1024, 3);
    prfl->setLayout(ppl2);

    //日志记录器
    Category &root = Category::getRoot().getInstance("cat");
    root.addAppender(poa);
    root.addAppender(prfl);

    //日志的过滤器
    root.setPriority(Priority::ERROR);

    size_t idx = 40;
    while(idx > 0)
    {
        root.emerg("This is an emerg message");
        root.fatal("This is an fatal message");
        root.alert("This is an alert essage");
        root.crit("This is an crit message");
        root.error("This is an error message");
        root.notice("This is an notice message");
        root.debug("This is an debug message");
        --idx;
    }

    Category::shutdown();
}
int main(int argc, char *argv[])
{
    test2();
    return 0;
}

需要注意的是,RollingFileAppender存储方式是先将文件存储到rollwxb.log中,如果rollwxb.log满了之后,将文件名改成rollwxb.log.1,继续将新的日志存入rollwxb.log;如果再满了,那就将rollwxb.log.1改成rollwxb.log.2,roLlwxb.log改成rollwxb.log.1…如此以往。当满了之后会删除旧的回滚文件,重复上述步骤。

作业

统计英文文章词频

统计一篇英文(The_Holy_Bible.txt)文章中出现的单词和词频。

输入:某篇文章的绝对路径

输出:词典文件dict.txt(词典中的内容为每一行都是一个“单词 词频”)

词典的存储格式如下。

|   a 66          |
|   abandon 77    |
|   public 88     |
|    ...........  |
|_________________|

代码设计:

struct Record
{
	string _word;
	int _frequency;
};

class Dictionary
{
public:
	//......
    void read(const std::string &filename);
    void store(const std::string &filename);
private:
    vector<Record> _dict;
};

提示:因为我们需要统计圣经文件中单词以及该单词在文件中出现的次数,所以可以看去读圣经文件,然后将单词存到数据结构中,并记录单词的次数,如果单词第二次出现的时候,只需要修改单词的次数(也就是这里说的单词的频率),这样当统计完整个圣经文件后,数据都存在数据结构vector了。接着遍历vector数据结构就可以将单词以及单词次数(也就是频率)存储到另外一个文件。(当然如果不存到另外一个文件,就只能打印到终端了)

注意:在读圣经文件的时候,有可能字符串是不合法的,比如:abc123 abc?这样的字符串,处理方式两种:直接不统计这样的字符串或者将非法字母去掉即可。

有如下代码:

头文件wordCount.h里面有

#ifndef _WORDCOUNT_H_
#define _WORDCOUNT_H_

#include <string>
#include <vector>

using std::vector;
using std::string;


struct Record
{
    Record(const string word, int frequency);
    string _word;
    int _frequency;
};

class Dictionary
{
public:
    void read(const string &filename);
    void store(const string &filename);
    string dealWord(const string &word);
    bool insert(const string &word);
private:
    vector<Record> _dict;
};

#endif

实现文件wordCount.cc里面有

#include <iostream>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
#include <cctype>
#include "wordCount.h"

using std::cout;
using std::cerr;
using std::endl;
using std::vector;
using std::string;
using std::ifstream;
using std::ofstream;
using std::istringstream;

Record::Record(const string word, int frequency)
:_word(word)
,_frequency(frequency)
{
}

void Dictionary::read(const string &filename)
{
    ifstream ifs(filename);
    if(!ifs)
    {
        cerr << "ifstream is not good." << endl;
        return;
    }

    string line;
    while(getline(ifs,line))
    { 
        istringstream iss(line);
        string word;
        while(iss >> word) 
        { 
            string newWord = dealWord(word);
            insert(newWord);
        }
    }
    ifs.close();
}

void Dictionary::store(const string &filename)
{
    ofstream ofs(filename);
    if(!ofs)
    {
        cerr << "ofstream is not good." << endl;
        return;
    }
    for(size_t idx = 0; idx != _dict.size(); ++idx)
    {
        ofs << _dict[idx]._word << " " << _dict[idx]._frequency << endl;
    }
}

string Dictionary::dealWord(const string &word)
{
    for (size_t idx = 0; idx != word.size(); ++idx)
    {
        /* if(word[idx] >= 'a' && word[idx] <= 'z') */
        if(!isalpha(word[idx]))
        {
            return string();
        }
    }
    return word;
}


bool Dictionary::insert(const string &word)
{
    if(word == string())
        return 0;

    size_t idx = 0;
    for(idx = 0; idx != _dict.size(); ++idx)
    {
        //如果单词已经存在
        if(word == _dict[idx]._word)
        {
            ++_dict[idx]._frequency;
            break;
        }
    }
    if(idx == _dict.size())
    {
        //如果单词不存在
        _dict.push_back(Record(word,1));
    }
    return 1;
}

测试文件testWordCount.cc有

#include "wordCount.h"

int main()
{
    Dictionary dic;
    dic.read("The_Holy_Bible.txt");
    dic.store("The_Holy_Bible_word_count.txt");
    return 0;
}

封装log4cpp

用所学过的类和对象的知识,封装log4cpp,让其使用起来更方便,要求:可以像printf一样,同时输出的日志信息中最好能有文件的名字,函数的名字及其所在的行号(这个在C/C++里面有对应的宏,可以查一下)

代码模板:

class Mylogger
{
public:
	void warn(const char *msg);
	void error(const char *msg);
	void debug(const char *msg);
	void info(const char *msg);
	
private:
	Mylogger();
	~Mylogger();
    
private:
  //......
};

		
void test0()
{
    //第一步,完成单例模式的写法
    Mylogger *log = Mylogger::getInstance();

    log->info("The log is info message");
    log->error("The log is error message");
    log->fatal("The log is fatal message");
    log->crit("The log is crit message");
}

void test1() 
{
    printf("hello,world\n");
    //第二步,像使用printf一样
    //只要求能输出纯字符串信息即可,不需要做到格式化输出
    LogInfo("The log is info message");
    LogError("The log is error message");
    LogWarn("The log is warn message");
    LogDebug("The log is debug message");
}

有如下代码:

头文件Mylogger.h有

#ifndef __MYLOGGER_H__
#define __MYLOGGER_H__
#include <log4cpp/Category.hh>
#include <iostream>
#include <string>
#include <sstream>

using std::cout;
using std::endl;
using std::string;
using std::ostringstream;

using log4cpp::Category;

class Mylogger
{
public:
	void warn(const char *msg);
	void error(const char *msg);
	void debug(const char *msg);
	void info(const char *msg);

    static Mylogger *getInstance();//实现单例模式
    static void destroy();

private:
	Mylogger();
	~Mylogger();
    
private:
    static Mylogger * _pInstance;
    Category &_root;//单例模式,保证日志记录器唯一
};

#define prefix(msg) (string(__FILE__) + string("; ")\
                                      + string(__FUNCTION__) + string("; ")\
                                      + string(intToString(__LINE__) )+ string("; ")\
                                      + msg).c_str()
#define logError(msg) Mylogger::getInstance()->error(prefix(msg))
#define logInfo(msg) Mylogger::getInstance()->info(prefix(msg))
#define logWarn(msg) Mylogger::getInstance()->warn(prefix(msg))
#define logDebug(msg) Mylogger::getInstance()->debug(prefix(msg))


#endif

实现文件Mylogger.cc有

#include <iostream>
#include <log4cpp/PatternLayout.hh>
#include <log4cpp/OstreamAppender.hh>
#include <log4cpp/RollingFileAppender.hh>
#include <log4cpp/FileAppender.hh>
#include <log4cpp/Category.hh>
#include <log4cpp/Priority.hh>
#include "Mylogger.h"

using std::cout;
using std::endl;
using namespace log4cpp;

Mylogger *Mylogger::_pInstance = nullptr;

Mylogger::Mylogger()
:_root(Category::getRoot().getInstance("Mylogger:"))
{
    cout << "Mylogger()" << endl;
    
    PatternLayout *ppl = new PatternLayout();
    ppl->setConversionPattern("%d [%p] %c: %m %n");
    
    PatternLayout *ppl2 = new PatternLayout();
    ppl2->setConversionPattern("%d [%p] %c: %m %n");
    
    OstreamAppender *poa = new OstreamAppender("Ostream", &cout);
    poa->setLayout(ppl);

    RollingFileAppender *prf1 = new RollingFileAppender("file","RollingFileLogger", 5*1024, 3);
    prf1->setLayout(ppl2);

    _root.addAppender(poa);
    _root.addAppender(prf1);

    _root.setPriority(Priority::DEBUG);

}

Mylogger::~Mylogger()
{
    cout << "~Mylogger()" << endl;
}



Mylogger *Mylogger::getInstance()
{
    if(_pInstance == nullptr)
    {
        _pInstance = new Mylogger();
    }
    return _pInstance;
}

void Mylogger::destroy()
{
    if(_pInstance)
    {
        delete [] _pInstance;
        _pInstance = nullptr;
    }
}

void Mylogger::warn(const char *msg)
{
    _root.warn(msg);
}

void Mylogger::error(const char *msg)
{
    _root.error(msg);
}

void Mylogger::debug(const char *msg)
{
    _root.debug(msg);
}

void Mylogger::info(const char *msg)
{
    _root.info(msg);
}

测试文件testMylogger.cc有

#include "Mylogger.h"

void test0()
{
    //第一步,完成单例模式的写法
    Mylogger *log = Mylogger::getInstance();

    log->info("The log is info message");
    log->error("The log is error message");
    log->debug("The log is debug message");
    log->warn("The log is warn message");
}

string intToString(int value)
{
    ostringstream oss;
    oss << value;
    return oss.str();
}

void test1() 
{
    //第二步,像使用printf一样
    //只要求能输出纯字符串信息即可,不需要做到格式化输出
    Mylogger *log = Mylogger::getInstance();

    log->info("The log is info message");
    log->error("The log is error message");
    log->debug("The log is debug message");
    log->warn("The log is warn message");

    logInfo("The log is info message");
    logError("The log is error message");
    logWarn("The log is warn message");
    logDebug("The log is debug message");
}
int main()
{
    test0();
    test1();
    return 0;
}

使用log4cpp

使用log4cpp格式化输出的信息,同时要求输出到终端、文件和回卷文件中。

提示:通过实践来感受log4cpp的使用,这是学习任何第三方库的第一步要做的事儿,先从模仿开始,再慢慢逐步理解。学会使用第三方库,是工作中的基本能力,一定要掌握方法论。

友元、运算符重载

友元

一般来说,类的私有成员只能在类的内部访问,类之外是不能访问它们的。但如果将其他类或函数设置为类的友元(friend),就可以访问了。用这个比喻形容友元可能比较恰当:将类比作一个家庭,类的private 成员相当于家庭的秘密,一般的外人是不允许探听这些秘密的,只有friend (朋友)才有能力探听这些秘密。

友元的形式可以分为友元函数友元类

class 类名
{
    friend 函数原型;
    friend class 类名;
    //...
};

目前为止,我们学过的函数形式有两种:全局函数(自由函数/普通函数)和成员函数。接下来,我们分别进行讨论,以类Point为例。

普通函数(全局函数/自由函数)

如果有两个点Point pt1和Point pt2,想要计算两点之间的距离。

#include <math.h>

class Point {
public:
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {}
    void print() const
    {
        cout << "(" << _ix
             << "," << _iy
             << ")";
    }
    
private:
    int _ix;
    int _iy;
};

double distance(const Point & lhs, const Point & rhs)
{
    return hypot(lhs._ix - rhs._ix, lhs._iy - rhs._iy);//error 无法访问私有数据成员,'int Point::_ix'... is private within this context
}

上面的hypot函数是在math.h文件中的普通函数,此时会出现错误:无法访问私有数据成员。需要在Point类中将其声明为友元。

#include <math.h>

class Point {
public:
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {}
    void print() const
    {
        cout << "(" << _ix
             << "," << _iy
             << ")";
    }
    friend double distance(const Point & lhs, const Point & rhs);//将全局函数distance声明为友元
    
private:
    int _ix;
    int _iy;
};

double distance(const Point & lhs, const Point & rhs)
{
    return hypot(lhs._ix - rhs._ix, lhs._iy - rhs._iy);
}

成员函数

假设类A有一个成员函数,该成员函数想去访问另一个类B类中的私有成员变量。这时候则可以在第二个类B中,声明第一个类A的那个成员函数为类B的友元函数,这样第一个类A的某个成员函数就可以访问第二个类B的私有成员变量了。

同样还是求取两个点之间的距离,现在我们再定义一个类Line ,由Line 中的成员函数distance完成:

写代码时候要注意类的前向声明时不会把类的成员函数、数据成员等类的内部细节一起声明。

#include <math.h>

class Point;//类的前向声明

class Line
{
public:
    double distance(const Point & lhs, const Point & rhs)
    {
        return hypot(lhs._ix - rhs._ix, lhs._iy - rhs._iy);//error invalid use of incomplete type 'const class Point'
    }
};


class Point {
public:
    Point(int ix = 0, int iy = 0);//实现省略
    void print() const;//实现省略
    
private:
    int _ix;
    int _iy;
};

此时会发生错误:对于不完整的类Point的非法使用(invalid use of incomplete type ‘const class Point’)。此时应该把Line类中的成员函数Line::distance放在类外面进行实现。

此时想要调用其他类中的成员函数也是可以在Point类中声明其为友元即可:

#include <math.h>

class Point;//类的前向声明

class Line
{
public:
    double distance(const Point & lhs, const Point & rhs);
};


class Point {
public:
    Point(int ix = 0, int iy = 0);//实现省略
    void print() const;//实现省略
    
    friend double Line::distance(const Point & lhs, const Point & rhs);//声明其他类中的成员函数为友元
    
private:
    int _ix;
    int _iy;
};

double Line::distance(const Point & lhs, const Point & rhs)//需要在Point类的实现后才能进行实现
{
    return hypot(lhs._ix - rhs._ix, lhs._iy - rhs._iy);
}

int main()
{
    cout  << Line().distance(Point(1, 2), Point(4,6));//通过匿名函数Line()进行调用,参数也为匿名函数Point(x, y)(const Point &lhs保证左值可以绑定到右值引用上)
}

友元类

如上的例子,假设类Line中不止有一个distance成员函数,还有其他成员函数,它们都需要访问Point 的私有成员,如果还像上面的方式一个一个设置友元,就比较繁琐了,可以直接将Line类设置为Point的友元。

class Point {
    //...
    friend class Line;
    //...
};

友元的一些特点:

  • 不受public、private、protected影响。
  • 重载函数是不同的函数,需要单独设置
  • 将类设置成友元时仅是将其中的所有成员函数设置成友元,不含数据成员
  • 单向、不具备传递性、不能被继承

友元在一定程度上将类的私有成员暴露出来,破坏了信息隐藏机制,但是友元的存在,使得类的接口扩展更为灵活,使用友元进行运算符重载从概念上也更容易理解一些,而且, C++ 规则已经极力地将友元的使用限制在了一定范围内,它是单向的、不具备传递性、不能被继承,所以,应尽力合理使用友元。

运算符重载

为什么要运算符重载?

C++ 预定义中的运算符的操作对象只局限于基本的内置数据类型,但是对于我们自定义的类型是没有办法操作的。

下面定义一个新的数据类型Complex来实现复数:

class Complex
{
public:
    Complex(double dreal = 0.0, double dimag = 0.0)
    : _dreal(dreal)
    , _dimag(dimag)
    {
        cout << "Complex(double = 0.0, double = 0.0)" << endl;
    }

    void print() const//这里的print函数仍应优化
    {
        cout << _dreal << " + " << _dimag << "i" << endl;
    }

    ~Complex()
    {
        cout << "~Complex()" << endl;
    }
    
private:
    double _dreal;
    double _dimag;
};

此时该类Complex定义的对象之间不能进行运算。需要我们对这么运算符进行重新定义,赋予其新的功能,以满足自身的需求。其本质就是函数重载函数多态

返回类型 operator 运算符(参数表)
{
    //...
}

运算符重载的规则

一般的运算符都可以重载,但也有如下不可重载的运算符:

  • 成员访问运算符.

  • 成员指针运算符.*

  • 三目运算符?:

  • 作用域运算符 ::

  • sizeof运算符

需要注意sizeof是一个运算符而非函数。

总结:“四个点与sizeof”不能进行重载。

此外还有以下规则:

  • ★为了防止用户对标准类型进行运算符重载, C++ 规定重载的运算符的操作对象必须至少有一个是自定义类型或枚举类型。
  • ★重载运算符之后,其优先级和结合性还是固定不变的。
  • ★重载不会改变运算符的用法,原来有几个操作数、操作数在左边还是在右边,这些都不会改变。
  • ★重载运算符函数不能有默认参数,否则就改变了运算符操作数的个数。
  • 重载逻辑运算符(&& , ||)后,不再具备短路求值特性。
  • 不能臆造一个并不存在的运算符,如@$等。

运算符重载的形式

以普通函数形式重载

想要访问私有数据成员时需要在类内定义另外的成员函数来获取。

如Complex对象无法进行加法,那我们需要重载+运算符。此前的定义中Complex的成员都是private,无法在类外访问。所以需要另外定义函数getReal()getImage()

class Complex {
public:
    //...
    double getReal() const { return _real; }
    double getImage() const { return _image; }//如果没有这个const会报错,因为operator+函数中的lhs、rhs是const修饰的
    //...
};

Complex operator+(const Complex & lhs, const Complex & rhs)
{
    return Complex(lhs.getReal() + rhs.getReal(),
    			   lhs.getImage() + rhs.getImage());
}

void test0()
{
    Complex c1(1, 2), c2(3, 4);
    Complex c3 = c1 + c2;
}

此时会这么调用:operator+(c1, c2)c1 + c2

理解一下Complex c3 = c1 + c2;过程:

首先进入operator+函数,此时先执行Complex的构造函数创建临时对象,再执行getReal()get.Image()函数,执行return语句时执行拷贝构造函数将值赋值给另一个临时对象,此时第一个临时对象的生命周期结束,执行析构函数;接着第二个临时对象执行拷贝构造函数将值赋值给c3,随后生命周期结束执行析构函数。

以成员函数形式重载

回到Complex的例子,如果以成员函数形式进行重载,则不需要定义getter函数:

class Complex {
public:
    //...
    Complex operator+(const Complex & lhs, const Complex & rhs)//error 此时有三个参数(隐含了this指针)
    {
        return Complex(lhs._real + rhs._real, lhs._image + rhs._image);
    }
};

用成员函数重载双目运算符时,左操作数无须用参数输入,而是通过隐含的this 指针传入,应该写成:

class Complex {
public:
    //...
    Complex operator+( const Complex & rhs)
    {
        return Complex(lhs._real + rhs._real, lhs._image + rhs._image);
    }
};

此时会这么调用:c1.operator+(c2)c1 + c2

以友元形式重载

如果以友元形式进行重载,不需要定义getter函数,保证了参数的个数是两个,而且可以访问类的私有成员。因此对于此处的+进行重载建议使用友元。

class Complex{
//...
    friend Complex operator+(const Complex & lhs, const Complex & rhs);
};

Complex operator+(const Complex & lhs, const Complex & rhs)//属于友元的普通函数类型
{
    return Complex(lhs._real + rhs._real, lhs._image + rhs._image);
}

特殊运算符的重载、类型转换、类域

特殊运算符的重载

以Complex类为例。

复合赋值运算符

赋值运算符有很多,普通赋值运算符有==,此外还有复合赋值运算符如加且赋值运算符+=、乘且赋值运算符*=等。以加且赋值运算符为例:

Complex &operator+=(const Complex &rhs)
{
    cout << "Complex &operator+=(const Complex &)" << endl;
    _dreal += rhs._dreal;
    _dimag += rhs._dimag;
    
    return *this;
}

为什么加法运算符重载返回类型没有引用(Complex)而加且赋值运算符重载返回类型的返回类型有引用(Complex &)?

因为返回类型为引用时要求返回值的生命周期并未结束。

赋值运算符建议使用成员函数来进行重载。在对象本身会发生改变的情况,都将这种运算符设置成成员函数。

自增/自减运算符

与赋值运算符相似,对象本身会发生改变,因此推荐以成员函数形式进行重载。

自增/自减运算符分别包含两个版本,即运算符前置形式(如++x )和运算符后置形式(如 x++ ),这两者进行的操作是不一样的。因此,当我们在对这两个运算符进行重载时,就必须区分前置和后置形式。

Complex &operator++()//前置形式
{
    cout << "Complex &operator++()" << endl;
    ++_dreal;
    ++_dimag;
    
    return *this;
}

为了区分前置和后置形式,必须为重载函数再增加一个int类型的参数,该参数仅仅用来告诉编译器这是一个运算符后置形式,在实际调用中不需要进行传参。

Complex &operator++(int)//后置形式(错误的)
{
    cout << "Complex &operator++(int)" << endl;
    _dreal++;//写成++_dreal;效果一样
    _dimag++;
    
    return *this;
}

此时后置形式是错误的,效果与前置形式一模一样。因此应该写成如下形式,此时返回的tmp中的值是未自增的值,但this中已经自增。

Complex operator++(int)//后置形式
{
    cout << "Complex &operator++(int)" << endl;
    Complex tmp(*this);
    _dreal++;//写成++_dreal;效果一样
    _dimag++;
    
    return tmp;
}

因为上面的实现不同,所以会有些有趣的性质:

  • 前置形式返回的值是左值,后置形式返回的值是右值
  • 前置形式效率比后置形式高

输入/输出流调用运算符

在之前的例子中,我们如果想打印一个对象时,常用的方法是通过定义一个print 成员函数来完成,但使用起来不太方便。我们希望打印一个对象,与打印一个整型数据在形式上没有差别,那就必须要重载<<运算符。

void test()
{
    int a = 1, b = 2;
    cout << a << b << endl;
    Complex pt1(1, 2), pt2(3, 4);
    cout << pt1 << pt2 << endl;
}

从上面的形式能看出,cout是左操作数,a或者pt1是右操作数,那输入输出流能重载为成员函数形式吗?我们假设是可以的,由于非静态成员函数的第一个参数是隐含的this指针,代表当前对象本身,这与其要求是冲突的。

输入输出流运算符只能是二元函数

如果要解决this指针的影响,可以用this指针代替rhs指针,但是这样会改变操作数os和对象的顺序,不满足运算符重载的规律。

ostream &operator<<(ostream &os)此时的参数顺序为this指针第一位、ostream &第二位。不符合调用时的cout << Complex(1, 2);的ostream &在前,Complex类对象在后。

因此>><<不能重载为成员函数,只能是非成员函数,如果涉及到要对类中私有成员进行访问,还得将非成员函数设置为类的友元函数

class Complex {
public:
    //...
    friend ostream & operator<<(ostream & os, const Complex & rhs);//不能删去引用,因为basic_ostream的拷贝构造函数被删去
    friend istream & operator>>(istream & is, Complex & rhs);//没有const,因为要求改变指针指向地址的内容
private:
    int _dreal;
    int _dimag;
};

ostream & operator<<(ostream & os, const Complex & rhs)
{
	cout << _dreal << " + " << _dimag << "i" << endl;//当然这个不是很好,可以输出结果更好看些,这里就不写了
    return os;
}

istream & operator>>(istream & is, Complex & rhs)
{
    is >> rhs._dreal;
    is >> rhs._dimag;//此时如果是输入非double类型数据,会发生错误,说明代码不够健壮。可以再优化一下。
    return is;
}

可以将istream &operator>>(istream, Complex&)函数再优化一下,如下:

void reaDouble(istream is double &number)
{
    while(is >> number, !is.eof())
    {
        if(is.bad())
        {
            cout << "stream is bad." << endl;
            return;
        }
        else if(is.fail())
        {
            cin.clear();
            cin.ignore(std::numeric_limits<std::streamsize>::max(),'\n');
            cout << "please input double type data." << endl;
        }
        else
        {
            break;
        }
    }
}

istream & operator>>(istream & is, Complex & rhs)
{
    cout <<"std::istream &operator>>(std::istream&, Complex &)" << endl;
    readDouble(is,rhs._dreal);
    readDouble(is,rhs._dimag);
    return is;
}

函数调用运算符

我们知道,普通函数执行时,有一个特点就是无记忆性,一个普通函数执行完毕,它所在的函数栈空间就会被销毁,所以普通函数执行时的状态信息,是无法保存下来的,这就让它无法应用在那些需要对每次的执行状态信息进行维护的场景。大家知道,我们学习了成员函数以后,有了对象的存在,对象执行某些操作之后,只要对象没有销毁,其状态就是可以保留下来的,但在函数作为参数传递(函数指针)时,会有障碍。为了解决这个问题(取代函数指针),C++引入了函数调用运算符。函数调用运算符的重载形式只能是成员函数形式

class FunctionObject
{
public:
    Foo(int count = 0): _count(count){}
    int operator()(int x)
    {
    	cout << " x = " << x << endl;
        cout << "_count = " << ++_count << endl;
        return x;
    }
    
    int operator()(int x, int y)
    {
        cout << " x + y = " << (x + y) << endl;
        cout << "_count = " << ++_count << endl;
        return x + y;
    }
    
    int add(int x, int y)
    {
        cout << " x + y = " << (x + y) << endl;
        cout << "_count = " << ++_count << endl;
        return x + y;
    }
    
private:
    int _count;//记录函数调用符执行的次数
}

int a = 0, b= 3, c = 4;
FunctionObject fo;
fo(a);
fo(a, c);
FunctionObject()(a, b);//匿名对象作为函数对象

可以将类对象作为一个函数使用。对于这种重构在了函数调用运算符的类创建的对象,我们称为函数对象。

下标访问运算符

下标访问运算符[]通常用于访问数组元素,它是一个二元运算符。如arr[idx]可以理解成arr是左操作数, idx是右操作数。

对下标访问运算符进行重载时,只能以成员函数形式进行,如果从函数的观点来看,语句arr[idx]; 可以解释为arr.operator[](idx);

class CharArray
{
public:
    CharArray(int size = 10)
    : _size(size)
    , _array(new char[_size]())
    {
        cout << "CharArray()" << endl;
    }
    
    char & operator[](size_t idx)
    {
        if(idx >=0 && idx < _size)
        {
            return _array[idx];
        } 
        else 
        {
            static char nullchar = '\0';
            cerr << "下标越界!\n";
            return nullchar;//如果写成“\0”会报错,因为此时是右值(左值不能绑定到右值上)。因此需要用static关键字修饰局部变量nullchar将其作为静态变量,延长其生命周期,所以函数返回值也可以用引用。
        }
    }
    
    const char & operator[](size_t idx) const
    {
        return _array[idx];
    }
    
    ~CharArray()
    {
        cout << "~CharArray()" << endl;
        if(_array)
        {
            delete [] _array;
            _array= nullptr;
        }     
    }
    
private:
    size_t _size;
    char * _array
};

int main(){
    const char *pstr = "helloworld!";
    CharArray ca(strlen(pstr) + 1);
    for(size_t idx = 0; idx != ca.size(); ++idx)
    {
        ca[idx] = pstr[idx];//放在赋值符号的左边的是左值,但是左值不一定就能放在赋值符号的左边。此时ca[idx]为左值,说明char & operator[](size_t idx)返回类型为左值。如果没有引用&指明返回值是是对象中的_array[idx],那就会出现错误。
    }
    cout << endl;
}

重载下标访问运算符的好处:

  • 提供安全机制,比C数组的下标要安全。
  • 不局限为基本类型,自定义类型也可以重载下标访问运算符
  • 下标访问运算符的参数可以是任意类型
Q. 什么时候需要加引用?
  1. 像是流这种拷贝构造函数删除的情况,传参以及函数的返回类型是类类型的时候一定要用引用。
  2. 如果实体的生命周期比函数的生命周期要大的话,尽量地将函数的返回类型写成引用。
  3. 如果函数的参数是类类型,尽量可以将其设计为引用。

重点是考虑拷贝构造函数的调用时机。

成员访问运算符

成员访问运算符包括箭头访问运算符->和解引用运算符*,我们先来看箭头运算符->

箭头运算符只能以成员函数的形式重载,其返回值必须是一个指针或者重载了箭头运算符的对象。来看下例子:

#include <iostream>

using std::cout;
using std::endl;

class Data
{
public:
    Data(int data = 0)
    : _data(data)
    {
        cout << "Data(int = 0)" << endl;
    }

    int getData() const
    {
        return _data;
    }

    ~Data()
    {
        cout << "~Data()" << endl;
    }
private:
    int _data;
};

class SecondLayer
{
public:
    SecondLayer(Data *pdata)
    : _pdata(pdata)
    {
        cout << "SecondLayer(Data *)" << endl;
    }

    //重载箭头访问运算符
    Data *operator->()
    {
        return _pdata;
    }

    Data &operator*()
    {
        return *_pdata;
    }

    ~SecondLayer()
    {
        cout << "~SecondLayer()" << endl;
        if(_pdata)
        {
            delete _pdata;
            _pdata = nullptr;
        }
    }
private:
    Data *_pdata;
};

class ThirdLayer
{
public:
    ThirdLayer(SecondLayer *psl)
    : _psl(psl)
    {
        cout << "ThirdLayer(SecondLayer *)" << endl;
    }

    //重载了箭头访问运算符
    SecondLayer &operator->()
    {
        return *_psl;
    }

    ~ThirdLayer()
    {
        cout << "~ThirdLayer()" << endl;
        if(_psl)
        {
            delete _psl;
            _psl = nullptr;
        }
    }
private:
    SecondLayer *_psl;
};

void test()
{
    SecondLayer sl(new Data(10)); //sl也是栈对象
    sl.operator->()->getData(); //相当于s1->getData();
    cout << "sl->getData() = " << sl->getData() << endl; 
    /* sl.operator*().getData(); */
    cout << "(*sl).getData() = " << (*sl).getData() << endl; 

    ThirdLayer tl(new SecondLayer(new Data(200)));//tl是栈对象
    tl.operator->().operator->()->getData();
    cout << "tl->getData() = " << tl->getData() << endl;
}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

s1->getData()调用本质上是sl.operator->()->getData()。sl本身只是一个栈对象,并不是一个指针,但是sl的使用与普通指针没有什么区别,原因是SecondLayer进行了箭头与解引用运算符的重载。此时sl.operator->()函数返回的值是Data类对象的指针,可以调用getData()成员函数。

此外,Data类对象不需要进行手动销毁,sl可以利用栈对象的销毁执行自己的析构函数,然后将托管的堆空间进行自动回收,这种指针具备智能指针的雏形。

智能指针的本质是利用栈对象的生命周期来回收内存。

同理,tl也可以进行如上步骤,只是要多嵌套一层,此时返回值是重载了箭头运算符的SecondLayer类对象sl,该对象再次调用重载了的箭头运算符,返回指针指向Data类对象,可以调用getData()成员函数。

总结

  • 所有的一元运算符,建议以成员函数的方式进行重载
  • 运算符= () [] -> *都必须以成员函数的形式进行重载
  • 运算符+= -=等复合赋值运算符函数都建议以成员函数形式进行重载
  • 其他的二元运算符,建议以非成员函数进行重载,往往以友元函数的形式进行重载

类型转换

由其他类型向自定义类型转换

一般使用的是隐式转换,需要构造函数作为支撑。如由Complex类向Point类进行转换,

class Complex
{
    friend class Point;//注意友元是写在这里
public:
    Complex(double dreal = 0.0, double dimag = 0.0);
    //...
        
private:
    double _dreal;
    double _dimag;
};

class Point
{
public:
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {
        cout << "Point(int = 0, int = 0)" << endl;
    }

    Point(const Complex &rhs)//依托于此构造函数实现转换
    : _ix(rhs._dreal)
    , _iy(rhs._dimag)
    {
        cout << "Point(const Complex &)" << endl;
    }

    ~Point();
    //...
    
private:
    int _ix;
    int _iy;
};

此时执行Point pt = Complex(2, 3)会执行Point(const Complex &)构造函数以实现隐式转换。

当然想要阻止隐式转换可以在相应的构造函数前加上explict关键字即可。

由自定义类型向其他类型转换

需要使用类型转换函数,这是一个特殊的成员函数。

operator 目标类型()
{
    //...
}

类型转换函数具有以下特点:

  • 必须是一个成员函数
  • 函数没有参数列表
  • 函数没有返回类型,但在函数体中执行return语句,返回目标类型的变量
  • 一般不建议使用这个语法
class Point
{
public:
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {
        cout << "Point(int = 0, int = 0)" << endl;
    }

    Point(const Complex &rhs)
    : _ix(rhs._dreal)
    , _iy(rhs._dimag)
    {
        cout << "Point(const Complex &)" << endl;
    }

    ~Point()
    {
        cout << "~Point()" << endl;
    }

    operator int()
    {
        cout << "operator int()" << endl;
        return _ix + _iy;
    }

    operator double()
    {
        cout << "operator double()" << endl;
        if(0 == _iy)
        {
            return 0.0;
        }
        else
        {
            return static_cast<double>(_ix)/_iy;
        }
    }

    operator Complex()
    {
        cout << "operator Complex()" << endl;
        return Complex(_ix, _iy);
    }

    friend std::ostream &operator<<(std::ostream &os, const Point &rhs);
private:
    int _ix;
    int _iy;
};

std::ostream &operator<<(std::ostream &os, const Point &rhs)
{
    os << "(" << rhs._ix
       << ", " << rhs._iy
       << ")";
    return os;
}

void test2()
{
    //从自定义类型向其他类型进行转换
    Point pt(1, 2);
    cout << "pt = " << pt << endl;//结果是pt = (1, 2)

    cout << endl;
    int ix = pt;
    cout << "ix = " << ix << endl;//结果是ix = 3

    cout << endl;
    double dy = pt;
    cout << "dy = " << dy << endl;//结果是dy = 0.5

    cout << endl;
    Complex c1 = pt;
    cout << "c1 = " << c1 << endl;//结果是1 + 2i
}

需要注意如果上面的pt没有进行输出运算符函数的重载,但是有且只有一个类型转换函数将其转换为可以被输出运算符函数输出的类型,那么在输出时也会自动进行类型转换。

但是不是很建议这么做。

class Point
{
public:
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {
        cout << "Point(int = 0, int = 0)" << endl;
    }

    ~Point();

    operator int()
    {
        cout << "operator int()" << endl;
        return _ix + _iy;
    }

private:
    int _ix;
    int _iy;
};


void test3()
{
    Point pt(1, 2);
    cout << "pt = " << pt << endl;//结果是pt = 8
}

注意不能有多个类型转换函数,否则会发生错误(不知道该运行哪一个类型转换函数)。

类域

如果没有发生屏蔽现象,可见域和作用域是相等的,但是如果发生屏蔽现象,类成员的可见域将小于作用域。此时可借助this指针或“类名::”形式指明所访问的是类成员,这有些类似于使用:: 访问全局变量。

#include <iostream>
int num = 1;
int x = 2;
using namespace lizi;
int num = 3;

class Example {
public:
    void print(int num)
    {
        cout << "形参num = " << num << endl;
        cout << "数据成员num = " << this->num << endl;
        cout << "数据成员num = " << Example::num << endl;
        cout << "命名空间中的num" << lizi::num << endl;
        cout << "全局变量num = " << ::num << endl;
    }
private:
    int num;
};

Example em(x)

全局作用域

一般地,绝大多数的C++类都是定义在全局作用域中。全局类具有全局作用域。

类作用域

一个类可以定义在另一个类的定义中,这是所谓嵌套类或者内部类。举例来说,如果类A定义在类B中,如果A的访问权限是public,那么A的作用域可以认为和B相同,但必须使用B::A的形式来访问A的类名;如果A的访问权限是private,那么只能在B类内使用类名来创建该类的对象,无法在B类的外部创建A类的对象。

块作用域

类的定义在代码块中,这是所谓局部类,该类完全被块包含,其作用域仅仅限于定义所在块,不能在块外使用类名声明该类的对象。

单例模式的自动释放、String的底层实现、写时复制与短字符串优化

单例模式的自动释放

单例模式的核心是将对象通过_pInstance指针来保存,在使用单例设计模式的过程中,也难免遇到内存泄露的问题,那么有没有一种方法能够让对象自动释放而不需要程序员手动释放呢?

如何检测内存泄露?

使用valgrind工具。

#安装方式
sudo apt-get install valgrind
#使用方式
valgrind --tool=memcheck --leak-check=full ./a.out

友元的形式

class Singleton
{
    friend class AutoRelease;
    //...
};

class AutoRelease
{
public:
    AutoRelease()
    {
        cout << "AutoRelease()" << endl;
    }
    
    ~AutoRelease()
    {
        cout << "~AutoRelease()" << endl;
        if(Singleton::_pInstance)
        {
            delete Singleton::_pInstance;
            Singleton::_pInstance = nullptr;
        }
    }
};

此时AutoRelease类会访问Singleton类的私有数据成员_pInstance,那显然需要将类AutoRelease设置为Singleton的友元

内部类+静态数据成员★

class Singleton
{
    //...
private:
    class AutoRelease
    {
    public:
        AutoRelease()
        {
            cout << "AutoRelease()" << endl;
        }

        ~AutoRelease()
        {
            cout << "~AutoRelease()" << endl;
            if(Singleton::_pInstance)
            {
                delete Singleton::_pInstance;
                Singleton::_pInstance = nullptr;
            }
        }
    };
private:
    static Singleton *_pInstance;
};

Singleton *Singleton::_pInstance = nullptr;

void test()
{
    Singleton::getInstance();
    Singleton::AutoRelease ar;//error 创建栈对象错误
}

如果只是使用内部类(一般设置成私有数据成员)的话,会发现无法调用Singleton::AutoRelease ar以创建栈对象。因此应该使用静态数据成员,创建一个AutoRelease类的对象作为Singleton类的数据成员。

class Singleton
{
    //...
private:
    class AutoRelease
    {
    public:
        AutoRelease()
        {
            cout << "AutoRelease()" << endl;
        }

        ~AutoRelease()
        {
            cout << "~AutoRelease()" << endl;
            if(Singleton::_pInstance)
            {
                delete Singleton::_pInstance;
                Singleton::_pInstance = nullptr;
            }
        }
    };
private:
    static Singleton *_pInstance;
    Singleton::AutoRelease _ar;
};

Singleton *Singleton::_pInstance = nullptr;

void test()
{
    Singleton::getInstance();
}

但此时会出现死锁:Singleton的析构函数被调用需要调用AutoRelease类的析构函数,而AutoRelease的析构函数被调用又需要Singleton的析构函数的调用。

那如何解决此问题呢?将AutoRelease创建的对象**_ar设置为静态数据成员**,此时就从堆区转移到了静态变量区,可以在进程结束时自动释放,由此再调用Singleton的析构函数。

饿汉模式 + atexit

atexit函数

< c‎ | program

定义于头文件 <stdlib.h>
int atexit( void (*func)(void) );

注册 func 所指向的函数,使它在程序正常终止(通过 exit() 或从 main() 返回)时得到调用。将以注册顺序的逆序调用这些函数,即首先执行最后调用的函数。

atexit 是线程安全的:多个线程调用此函数不会引入数据竞争。

饱汉模式

getInstance函数中使用atexit函数来注册destroy函数,保证在程序正常终止时会调用destroy函数。此时不需要AutoRelease类。

class Singleton
{
public:
    static Singleton *getInstance()
    {
        if(_pInstance == nullptr)
        {
            _pInstance = new Singleton();
            atexit(destroy);
        }
        return _pInstance;
    }
    static void destory();//实现省略
    
    //...
};

Singleton *Singleton::_pInstance = nullptr;

对于有多线程的进程,此时会发生一个问题:第6-10行作为临界区,但是可能有多个线程进入创建多个对象,未保证单例模式。

饿汉模式

为解决上面的问题,我们可以对临界区进行上锁。也可以使用饿汉模式,即将静态数据成员_pInstance初始化的时候直接设置成getInstance(),将单例模式Singleton类对象在此时创建,那么多线程进入时均会直接执行return _pInstance;,保证临界区安全。

class Singleton
{
public:
    static Singleton *getInstance()
    {
        if(_pInstance == nullptr)
        {
            _pInstance = new Singleton();
            atexit(destroy);
        }
        return _pInstance;
    }
    static void destory();//实现省略
    
    //...
};

Singleton *Singleton::_pInstance = Singleton::getInstance();//饿汉模式
//也可以写成Singleton *Singleton::_pInstance = getInstance();,相当于getInstance借用了前面的_pInsatance的Singleton

pthread_once + atexit

pthread_once

NAME pthread_once - once-only initialization

SYNOPSIS #include <pthread.h>

pthread_once_t once_control = PTHREAD_ONCE_INIT;

int pthread_once(pthread_once_t *once_control, void (*init_routine) (void));

DESCRIPTION

  • The purpose of pthread_once is to ensure that a piece of initialization code is executed at most once. The once_control argument points to a static or extern variable statically initialized to PTHREAD_ONCE_INIT.

  • The first time pthread_once is called with a given once_control argument, it calls init_routine with no argument and changes the value of the once_control variable to record that initialization has been performed. Subsequent calls to pthread_once with the same once_control argument do nothing.

实现
class Singleton
{
public:
    static Singleton *getInstance()
    {
        pthread_once(&_once, init);
        return _pInstance;
    }
    
    static void init()//静态成员函数不能访问非静态成员函数
    {
        _pInstance = new Singleton();
        atexit(destroy);
    }
    //...
};

Singleton *Singleton::_pInstance = nullptr;
pthread_once_t Singleton::_once = PTHREAD_ONCE_INIT;

能够“ensure that a piece of initialization code is executed at most once”。此外pthread_once函数与平台相关,只在Linux系统下可用

此外,进行调用时需要加上参数-lpthread

String的底层实现★

我们都知道,std::string的一些基本功能和用法了,但它底层到底是如何实现的呢?其实在std::string的历史中,出现过几种不同的方式。下面我们来一一揭晓。 我们可以从一个简单的问题来探索,一个std::string对象占据的内存空间有多大,即sizeof(std::string)的值为多大?如果我们在不同的编译器(VC++, GNU, Clang++)上去测试,可能会发现其值并不相同;即使是GNU,不同的版本,获取的值也是不同的。

虽然历史上的实现有多种,但基本上有三种方式:

  • Eager Copy(深拷贝)

  • COW(Copy-On-Write 写时复制):写时深拷贝,读时浅拷贝+引用计数

  • SSO(Short String Optimization 短字符串优化):当字符串的长度小于16字节时位于栈上;当字符串大于等于16字节时位于堆上。

每种实现,std::string都包含了下面的信息:

  • 字符串的大小
  • 能够容纳的字符数量
  • 字符串内容本身

Eager Copy

无论什么情况,都采用深拷贝。假设字符串的长度为n,时间复杂度均为O(n)。

COW(Copy-On-Write)

当两个std::string发生复制构造或者赋值(不改变内容)时,不会复制字符串内容,而是增加一个引用计数,然后字符串指针进行浅拷贝,其执行效率为O(1)。

只有当需要修改其中一个字符串内容时,才执行真正的复制,此时执行效率为O(n)。

Ubuntu 14.04使用写时复制策略

引用计数放在哪里比较好?

首先,引用计数在进程运行时会改变,所以不能存放在程序代码区、文字常量区。

然后,引用计数不能全局共享,而是应该在执行浅拷贝的字符串之间共享,因此也不能放在全局静态区。

再者,引用计数应该和字符串放在一起,而不是放在String对象里,否则每个String对象都会有这个引用计数,难以保证同步,因此也不能放在栈区。

class String
{
public:
    String();
    String(const String &rhs)
    :_pstr(rhs._pstr)
    ,_refCount(rhs._refCount)
    {
        rhs._refCount++; //error 此时无法修改rhs的值
        _refCount++;
    }
private:
    char *_pstr;
    int _refCount;
};

所以,引用计数可以放在堆区。最后,引用计数作为一个int类型数据,最好是放在字符串前面,因为其大小固定,可以使用字符串的首地址直接计算得到。

具体实现

为了实现简单,可以在std::String中只有一个数据成员_pstr,用来指向堆空间。

class String
{
public:
    String()
    :_pstr(new char[5] + 4)//4B用来存储RefCount值,_pstr向后偏移4B,指向字符串第一个字符的位置(如果为空就是\0)
    {
        cout << "String()" << endl;
        *(int *)(_pstr - 4) = 1;//先将_pstr - 4强制转换成int *,可以一次操作4B,随后解引用,将RefCount值设置为1
    }
    
    String(const char *pstr)
    :_pstr(new char[strlen(pstr) + 5]() + 4)
    {
        cout << "String(const char *)" << endl;
        strcpy(_pstr, pstr);//注意_pstr指针指向的位置
        *(int *)(_pstr - 4) = 1;
    }
    
    String(const String &rhs)
    :_pstr(rhs._pstr)
    {
        cout << "String(const String &)" << endl;
        ++*(int *)(_pstr - 4);
    }
    
    String &operator=(const String &rhs)
    {
        cout << "String &operator=(const String &)" << endl;
        if(this != &rhs)//1.防止自复制
        {
            if(--*(int *)(_pstr - 4) == 0)//2.判断RefCount值为0的时候释放左操作数
            {
            	delete [] (_pstr - 4);
            }
            _pstr = rhs._pstr;//3.浅拷贝
            ++*(int *)(_pstr - 4);
        }
        return *this;
    }
    
    ~String()
    {
        cout << "~String()" << endl;
        if(--*(int *)(_pstr - 4) == 0)
        {
            delete [] (_pstr - 4);
        }
    }
    
    friend std::ostream &opeartor<<(std::ostream &os, const String &rhs)
    {
        if(rhs._pstr)
        {
            os << rhs._pstr;
        }
        return os;
    }
    
    const char *c_str() const
    {
        return _pstr;
    }
    
    int getRefCount() const
    {
        return *(int *)(_pstr - 4);
    }
    
private:
    char *_pstr;
};
void test()
{
    String s1("hello");
    String s2 = s1;
    
    cout << "s1 = " << s1 << endl;
    cout << "s2 = " << s2 << endl;
    
    printf("&s1 = %p\n", s1.c_str());
    printf("&s2 = %p\n", s2.c_str());
    
    cout << "s1.getRefCount() = " << s1.getRefCount() << endl;
    cout << "s2.getRefCount() = " << s2.getRefCount() << endl;
    
    String s3("world");
    cout << "s1.getRefCount() = " << s1.getRefCount() << endl;
    cout << "s2.getRefCount() = " << s2.getRefCount() << endl;
    cout << "s3.getRefCount() = " << s3.getRefCount() << endl;
    
    s3 = s1;
    cout << "s1.getRefCount() = " << s1.getRefCount() << endl;
    cout << "s2.getRefCount() = " << s2.getRefCount() << endl;
    cout << "s3.getRefCount() = " << s3.getRefCount() << endl;
}

此时的结果是

String(const char *)
String(const String &)
s1 = hello
s2 = hello
&s1 = 0x557a466fae74
&s2 = 0x557a466fae74
s1.getRefCount() = 2
s2.getRefCount() = 2
String(const char *)
s1.getRefCount() = 2
s2.getRefCount() = 2
s3.getRefCount() = 1
String &operator=(const String &)
s1.getRefCount() = 3
s2.getRefCount() = 3
s3.getRefCount() = 3
~String()
~String()
~String()

此外,还可以再加入使用下标运算符修改String类中的字符串的值——下标运算符函数重载。

class String
{
	//...
public:
    char &operator[](size_t idx)
    {
        cout << "char &operator[](size_t)" << endl;
        if(idx < size())//判断是否越界
        {
            if(*(int *)(_pstr - 4) > 1)//判断引用计数是否是1,以判断是否共享
            {
                char *ptmp = new char[size() + 5]() + 4;//申请新的一块空间
                strcpy(ptmp, _pstr);//复制到新空间 
                --*(int *)(_pstr -  4);//原引用计数-1
                
                _pstr = ptmp;
                *(int *)(_pstr - 4) = 1;//新的空间中的引用计数:=1
            }
            return _pstr[idx];
        }
        else//越界返回nullchar
        {
            static char nullchar = '\0';
            return nullchar;//注意不能返回右值
        }
    }
    
    size_t size()
    {
        return strlen(_pstr);
    }
};

假设原来为

s1.getRefCount() = 3
s2.getRefCount() = 3
s3.getRefCount() = 3

此时如果运行s3[0] = 'H';,那么有

char &operator[](size_t)
s1.getRefCount() = 2
s2.getRefCount() = 2
s3.getRefCount() = 1

说明此时进行了深拷贝。

为了进一步优化,在String类中会有很多相同的操作,如引用计数+1,引用计数-1,引用计数设置为1,释放左操作数。可以将这些操作统一写成函数,便于使用。最终有:

class String
{
public:
    String()
    :_pstr(new char[5] + 4)
    {
        cout << "String()" << endl;
        initRefCount();
    }
    
    String(const char *pstr)
    :_pstr(new char[strlen(pstr) + 5]() + 4)
    {
        cout << "String(const char *)" << endl;
        strcpy(_pstr, pstr);
        initRefCount();
    }
    
    String(const String &rhs)
    :_pstr(rhs._pstr)
    {
        cout << "String(const String &)" << endl;
        increaseRefCount();
    }
    
    String &operator=(const String &rhs)
    {
        cout << "String &operator=(const String &)" << endl;
        if(this != &rhs)
        {
            release();
            _pstr = rhs._pstr;
            increaseRefCount();
        }
        return *this;
    }
    
    ~String()
    {
        cout << "~String()" << endl;
		release();
    }
    
    friend std::ostream &opeartor<<(std::ostream &os, const String &rhs)
    {
        if(rhs._pstr)
        {
            os << rhs._pstr;
        }
        return os;
    }
    
    char &operator[](size_t idx)
    {
        cout << "char &operator[](size_t)" << endl;
        if(idx < size())
        {
            if(getRefCount() > 1)
            {
                char *ptmp = new char[size() + 5]() + 4;
                strcpy(ptmp, _pstr);
                decreaseRefCount();
                
                _pstr = ptmp;
                initRefCount();
            }
            return _pstr[idx];
        }
        else
        {
            static char nullchar = '\0';
            return nullchar;
        }
    }
    
    const char *c_str() const
    {
        return _pstr;
    }
    
    size_t size()
    {
        return strlen(_pstr);
    }
    
    int getRefCount() const
    {
        return *(int *)(_pstr - 4);
    }

private:
    void release()
    {
        decreaseRefCount();
        if(getRefCount() == 0)
        {
            delete [] (_pstr - 4);
        }
    }
    
    void initRefCount()
    {
        *(int *)(_pstr - 4) = 1;
    }
    
    void increaseRefCount()
    {
        ++*(int *)(_pstr - 4);
    }
    void decreaseRefCount()
    {
        --*(int *)(_pstr - 4);
    }
    
private:
    char *_pstr;
};

但是此时还是有问题:在调用下标访问运算符时,也会执行if(getRefCount() > 1)后的语句,导致即使是读取也会进行深拷贝。

那怎么处理才能区分读和写操作,如s3[0] = 'H';cout << s3[0] << endl;呢?应该重载赋值运算符和输出流运算符。

但是如s3[0] = 'H';,此时赋值运算符函数的两个操作数都是char类型,不符合运算符重载的规则。那么我们需要将其中一个char修改成类类型,命名为CharProxy。此时会有如下情况CharProxy = char;cout << CharProxy;。只需要重载CharProxy中的运算符。

class String
{
    //...
private:
    class CharProxy
    {
    public:
        CharProxy(String &self, size_t idx)
        :_self(self)
        ,_idx(idx)
        {}

        char &operator=(const char &ch);
        friend std::ostream &operator<<(std::ostream &os, const CharProxy &rhs);

    private:
        String &_self;
        size_t _idx;
    };
    
public:
    CharProxy operator[](size_t idx)
    {
        return CharProxy(*this, idx);
    }
    
    friend std::ostream &operator<<(std::ostream &os, const CharProxy &rhs);
    friend std::ostream &operator<<(std::ostream &os, const String &rhs);
    //...
};

//读操作,cout << s;
std::ostream &operator<<(std::ostream &os, const String &rhs)
{
    if(rhs._pstr)
    {
        os << rhs._pstr;
    }
    return os;
}

//读操作,cout << s[0];
std::ostream &operator<<(std::ostream &os, const String::CharProxy &rhs)//要作为CharProxy和String的双友元才能访问String里面的私有数据成员CharProxy
{
    os << rhs._self._pstr[rhs._idx];
    return os;
}

//写操作,s[0] = 'H';
char &String::CharProxy::operator=(const char &ch)
{
    if(_idx < _self.size())
    {
        if(_self.getRefCount() > 1)
        {
            char *ptmp = new char[_self.size() + 5]() + 4;
            strcpy(ptmp, _self._pstr);
            _self.decreaseRefCount();

            _self._pstr = ptmp;
            _self.initRefCount();
        }
        _self._pstr[_idx] = ch;
        return _self._pstr[idx];
    }
    else
    {
        static char nullchar = '\0';
        return nullchar;
    }
}

此时有s[0] = 'H';相当于s.operator[](0).operator('H');cout << s[0];相当于operator<<(cout, s.operator[](0));

不过此时的双友元的写法比较繁琐,可以使用类型转换函数来优化:

class String
{
    //...
private:
    class CharProxy
    {
    public:
        CharProxy(String &self, size_t idx);
        char &operator=(const char &ch);
        
        operator char()
        {
            return _self._pstr[_idx];
        }

    private:
        String &_self;
        size_t _idx;
    };
    
public:
    CharProxy operator[](size_t idx);
    friend std::ostream &operator<<(std::ostream &os, const String &rhs);
    //...
};

此时只需要一个友元函数也可以输出cout << s[0];,在此时会将CharProxy进行类型转换到char进行输出,而不是需要对运算符进行重载。

当然即使到了现在,还是有很多不足。

SSO(Short String Optimization)

当字符串的长度(Capacity)小于16B的时候,申请在栈上,直接在buffer中存储;当字符串的长度大于等于16B的时候,申请在堆上,此时std::string中的数据成员buffer存放一个指针,指向堆上的该字符串。

set和map的使用

set容器

参考:C++ STL Set(集合) - 菜鸟教程 (cainiaojc.com)和C++ API文档。

特征

定义于头文件 <set>
template< class Key, class Compare = std::less<Key>, class Allocator = std::allocator<Key> > class set;(1)
namespace pmr { template <class Key, class Compare = std::less<Key» using set = std::set<Key, Compare, std::pmr::polymorphic_allocator<Key»; }(2)(C++17 起)

std::set 是关联容器,含有 Key 类型对象的已排序集。用比较函数 比较 (Compare) 进行排序。搜索、移除和插入拥有对数复杂度。 set 通常以红黑树实现。

屏幕截图 2024-02-02 204129

begin指向第一个元素,end指向最后一个元素的下一个位置。

例:

int main()
{
    set<int> number = {1, 3, 5, 7, 3, 9};
    
    for(auto it = number.begin();it != number.end(); ++it)
    {
        cout << *it << ' ';
    }
    cout << endl;
    return 0;
}

此时输出的结果为

1 3 5 7 9

说明set有如下特征

  • 存放key类型的元素,key值是唯一的,不能重复
  • 默认情况会按照key值进行升序排列

set容器属于双向迭代器。

常用操作

查找

count函数用于返回在容器中找到的元素数。由于set容器不包含任何重复元素,因此如果set容器中存在val值的元素,则此函数实际上返回1,否则返回0。

find函数用于查找具有给定值 val 的元素。如果找到元素,则返回指向该元素的迭代器,否则返回指向集合末尾的迭代器,即set :: end()。

int main()
{
    set<int> number = {1, 3, 5, 7, 3, 9};
    
    size_t cnt = number.count(5);//查找元素5的个数并返回值
    
    set<int>::iterator it = number.find(17);//查找元素17并返回迭代器
    if(it == number.end())
    {
        cout << "该元素不在set中" << endl;
    }
    else
    {
        cout << "该元素在set中" << endl;
    }
}
插入

C ++ set insert()用于在集合中插入新元素

因为元素键在集合中是唯一的,所以插入操作首先检查给定键是否已存在于集合中,如果键存在于集合中,则不会将其插入到集合中并迭代到现有的迭代器返回键,否则将新元素插入集合。

//普通引用方式传参
pair<iterator,bool> insert (const value_type& val);

此处的返回值pair是一个结构体,定义在头文件utility中。返回值中返回布尔对以表示是否发生插入,并返回指向新插入元素的迭代器。可以用first和second分别查看成员对象。

例:

int main()
{
    set<int> number = {1, 3, 5, 7, 3, 9};
    
    pair<set<int>::iterator, bool> ret = number.insert(8);
    if(ret.second)//使用返回值pair中的second来判断是否成功
    {
        cout << "success" << *ret.first << endl;
    }
    else
    {
        cout << "fail" << endl;
    }
}
不支持下标访问与修改

set容器并不是随机访问迭代器,因此不支持下标访问。set容器也不支持修改。

map容器

特征

std::map 是有序键值对容器,它的元素的键是唯一的。用比较函数 Compare 排序键。搜索、移除和插入操作拥有对数复杂度。 map 通常实现为红黑树

template < class Key,                    //map::key_type
           class T,                      //map::mapped_type
           class Compare = less,         //map::key_compare
           class Alloc = allocator<pair> //map::allocator_type
           > class map;
  • **key:**要存储在map中的键的数据类型。
  • **type:**要存储在map中的值的数据类型。
  • **compare:**一个比较类,它接受两个bool类型相同的参数,并返回一个值。此参数是可选的,二进制谓词less <“ key”>是默认值。
  • **alloc:**分配器对象的类型。此参数是可选的,默认值为分配器。

后两个使用默认参数,因此创建map容器一般使用如下语句创建

map example;//形式1
map<int, string> number;//形式2
map<int, string> employee = {pair<int, string>(1, "John"),
                             pair<int,string>(2, "Amy")};//形式3
map<int, string> employee = {{1, "John"},
                             {2, "Amy"}};//形式4
map<int, string> employee = {make_pair(1,"John")
    						 make_pair(2,"Amy")};//形式5

那么我们初始化一个map容器并输出

map<int, string> employee = {pair<int, string>(1, "John"),
                             pair<int,string>(2, "Amy"),
                             {1, "Johnson"},
                             {2, "Amy"},
                             make_pair(3,"John")};

for(auto it = number.begin();it != number.end(); ++it)
{
    cout << it->first << " " << it->second << endl;
}

输出为

1 John
2 Amy
3 John

有如下结论

  • map中存放的是pair类型,即key-value类型
  • key值唯一,不可重复;value值可以重复
  • 默认按照key值升序排列

常用操作

查找

C ++ map find()函数用于查找具有给定键值k的元素。如果找到该元素,则返回指向该元素的迭代器。否则,它返回一个指向map末尾的迭代器,即map :: end()。

C ++ map **count()**函数用于返回在容器中找到与给定键匹配的元素数。由于map容器的键不包含任何重复元素,因此此函数实际上返回1或者返回0。

map<int, string> employee = {pair<int, string>(1, "John"),
                             pair<int,string>(2, "Amy"),
                             {1, "Johnson"},
                             {2, "Amy"},
                             make_pair(3,"John")};

size_t cnt = employee.count(1);
map<int, string>::iterator it = employee.find(1);
插入

C ++ map insert()函数用于在map中插入新元素

因为元素键在map中是唯一的,所以插入操作首先检查给定键是否已存在于map中,如果键已存在于map中,则它不会插入map中,并且迭代器将迭代到现有键返回,否则在map中插入新元素。

需要注意的是,返回值是一个布尔对来指示是否发生插入,并返回一个指向新插入元素的迭代器,即pair<map<T1,T2>::iterator, bool>

pair<map<int ,string>::iterator, bool> ret = employee.insert({4, "King"});

if(ret.second)
{
    cout << "插入成功" << ret.first->first << " " << ret.first->second << endl;//ret.first指代的就是map迭代器
}
else
{
    cout << "插入失败" << endl;
}
下标

C ++ map operator []函数用于使用给定键值访问map中的元素,如下,

 cout << employee[1];
 cout << employee[5];

它类似于**at()函数。它们之间的唯一区别是,如果map中不存在所访问的键,则抛出异常;另一方面,如果map中不存在该键,则operator[]**将键插入map中。

#include <iostream>
#include <string>
#include <map>

using namespace std;

int main ()
{
    map<char,string> mp = {
                            { 'a',"Java"},
                            { 'b', "C++"  },
                            { 'c', "Python" }};

    cout << endl << mp['a'];
    cout << endl << mp['b'];
    cout << endl << mp['c'];
    
    mp['d'] = "SQL";           
    cout << endl << mp['d'];
     
    try
    {
        mp.at('z'); //由于map中没有值为z的键,因此会抛出异常
    }
    catch(const out_of_range &e) 
    {
    	cout << endl << "\n超出范围异常 " << e.what();
    }
	return 0;
}

继承、基类与派生类对象

继承的基本概念

基本概念

C++是面向对象的语言,有四大基本特征:抽象、封装、继承、多态。

继承就是从既有类创建新类的过程。

  • 鸟类:既有类。也称基类、父类。
  • 啄木鸟:创建的新类。也称派生类、子类。

语法形式

class 基类名
{
    //...
};

class 派生类名 
: public/protected/private 基类名
{
    //...
};

以Point类作为基类创建派生类Point3D。

class Point3D
: public Point
{
public:
    Point3D(int x, int y, int z)
    : Point(x, y)
    , _z(z)
    {
        cout << "Point3D(int, int, int)" << endl;
    }

private:
    int _z;
};

派生类生成的步骤★

  1. 吸收基类的成员
  2. 改造基类的成员
  3. 添加自己新的成员

继承的局限

不管是哪种继承方式,下面这些基类的特征是不能从基类继承下来的:

  • 构造函数
  • 析构函数
  • 用户重载的operator new/delete运算符
  • 用户重载的operator=运算符
  • 友元关系

与创建/删除有关的函数以及友元关系不能够继承。

不同继承方式的特点★

继承方式有3种:

  • public(公有)继承
  • protected(保护)继承
  • private(私有)继承(默认继承方式)
继承方式基类成员访问权限在派生类中访问权限派生类对象访问

公有继承
public
protected
private
public●
protected●
不可直接访问▲
可直接访问■
不可直接访问■
不可直接访问■

保护继承
public
protected
private
protected●
protected●
不可直接访问▲

不可直接访问■

私有继承
public
protected
private
private●
private●
不可直接访问▲

不可直接访问■

可以发现派生类中的访问权限其实是继承方式与基类成员访问权限的交集

通过继承,除了基类私有成员以外的其它所有数据成员和成员函数,派生类中可以直接访问。

总结:派生类的访问权限规则如下:

  1. 不管以什么继承方式,派生类内部都不能访问基类的私有成员▲
  2. 不管以什么继承方式,派生类内部除了基类的私有成员不可以访问外,其他都可以访问●
  3. 不管以什么继承方式,派生类对象除了公有继承基类中的公有成员可以访问外,其他的一律不能访问

私有继承和保护继承的区别

保护继承基类中的非私有成员,在后续可以无限的保护继承下去,并且可以被访问;但是私有继承基类中的非私有成员,当继承两次以上,在后续的派生类中是不能被访问的。

派生类对象的创建与销毁

派生类构造函数的形式

一般格式:

派生类名(总参数表): 基类构造函数(参数表)
{
	//函数体
};

四种情况

对于派生类对象的构造,我们分为4种情况进行讨论:

  1. 如果派生类有显式定义构造函数,而基类没有显示定义构造函数,则创建派生类的对象时,派生类相应的构造函数会被自动调用,此时自动调用了基类缺省的无参构造函数,然后再执行派生类构造函数的函数体。

    注意不能说成“创建派生类对象的时候,会调用基类的构造函数”。

    class Base
    {
    pulbic:
        Base()//基类有无参的默认构造函数
        {
       		cout << "Base()" << endl;
        }
    };
    
    class Derived
    : public Base
    {
    public:
        Derived(long derived)//派生类有显式定义构造函数
        :Base()//等效于没写
        : _derived(derived)
        {
        	cout << "Derived(long)" << endl;
        }
        long _derived;
    };
    
    void test()
    {
    	Derived d(1);
    }
    

    此时运行结果为

    Base()
    Derived(long)
    
  2. 如果派生类没有显式定义构造函数而基类有显示定义构造函数,则基类必须拥有默认构造函数,否则就会报错。

    class Base
    {
    pulbic:
        Base(long base)
        : _base(base)//基类没有无参的默认构造函数
        {
       		cout << "Base(long)" << endl;
        }
    };
    
    class Derived
    : public Base
    {
        //派生类里没有显式定义构造函数
    };
    
    void test()
    {
    	Derived d();//error
    }
    

    总结:默认情况下,在创建派生类对象的时候,会借助基类的无参构造函数,如果基类没有无参构造函数,那么就会报错。

  3. 如果派生类有构造函数,基类有默认构造函数,则创建派生类的对象时,基类的默认构造函数会自动调用。

    如果想调用基类的有参构造函数,必须要在派生类构造函数的初始化列表中显式调用基类的有参构造函数 。

    class Base
    {
    pulbic:
        Base()
        {
       		cout << "Base()" << endl;
        }
    
        Base(long base)
        : _base(base)//基类的有参构造函数
        {
       		cout << "Base(long)" << endl;
        }
    };
    
    class Derived
    : public Base
    {
    public:
        Derived(long derived)
        : Base(derived)//显式调用基类的有参构造函数
        , _derived(derived)
        {
            cout << "Derived(long)" << endl;
        }
    private:
        long _derived;
    };
    
    void test()
    {
    	Derived d();//error
    }
    

    如果不在派生类中的构造函数初始化列表中显式写出基类的有参构造函数,那么就会默认调用基类的默认构造函数(无参)。

  4. 如果派生类和基类都有构造函数,但基类没有默认的无参构造函数,即基类的构造函数均带有参数,则派生类的每一个构造函数必须在其初始化列表中显示的去调用基类的某个带参的构造函数。

    如果派生类的初始化列表中没有显示调用则会出错,因为基类中没有默认的构造函数。

总结:不管基类与派生类的构造函数的形式如何,当创建派生类对象的时候,会调用派生类的构造函数,为了完成从基类吸收过来数据成员的初始化,会默认调用基类的无参构造函数,如果基类的无参构造函数没有提供,就会报错。所以最好可以显示的在派生类的构造函数的初始化列表中,将需要调用的基类的构造函数显示的写出来,那么这样就不会报错了。

构造函数的调用顺序

派生类构造函数实现的功能,或者说调用顺序为:

  1. 完成对象所占整块内存的开辟,由系统在调用构造函数时自动完成。
  2. 调用基类的构造函数完成基类成员的初始化。
  3. 若派生类中含对象成员、const成员或引用成员,则必须在初始化表中完成其初始化。
  4. 派生类构造函数体执行。
Base(long)
Test()//此处的Test()是子对象的构造函数,即第三步
Derived(long, long)

析构函数的调用顺序

执行顺序是先执行派生类的析构函数,再执行基类的析构函数,这和执行构造函数时的顺序正好相反。当考虑对象成员时,继承机制下析构函数的调用顺序:

  1. 先调用派生类的析构函数

  2. 再调用派生类中成员对象的析构函数

  3. 最后调用普通基类的析构函数★

    自动调用基类的析构函数,不需要任何条件。

~Derived()
~Test()
~Base()

多基继承(多基派生)★

定义形式

定义形式如下

class 派生类
: public/protected/private 基类1
, ...
, public/protected/private 基类n
{
    //...
};

对于多继承而言,最好对每个基类都写继承方式,继承方式的关键字是不能共用的,如果不写继承方式,默认是private(私有)继承。如下,A被公有继承,B、C被私有继承。

class D
: public A, B, C
{
    
};

构建与销毁

各基类构造函数和析构函数的执行顺序与其在初始化表中的顺序无关,而是由定义派生类时指定的基类顺序决定的。

class D
: public A
, public B
, public C
{
    D()
    : B()
    , C()
    , A()
    {
        //...
    }
};

执行顺序是

A()
B()
C()
D()
~D()
~C()
~B()
~A()

成员名冲突的二义性

在多基继承时,如果多个基类中存在同名成员的情况,造成编译器无从判断具体要访问的哪个基类中的成员,则称为对基类成员访问的二义性问题。

成员函数的访问冲突

如下面的例子,我们先定义3个不同的类A、B、C,这3个类中都有一个同名成员函数print,然后让类D继承自A、B、C,则当创建D的对象d,用d调用成员函数print时,发生编译错误。

此时应该使用类名与作用域限定符的形式来指定。

D d;
d.print();//error
d.A::print();
d.B::print();
数据成员存储(菱形继承)二义性

如果发生如下继承:

7

在D类对象中,会有来自两条不同路径的共同基类(类A)的双重拷贝。此时如果A中有一个long型对象,那么使得A、B、C、D的大小为8B、8B、8B、16B。D中有两个相同名字的对象。

此时,我们应该使用虚拟继承。

class A
{
public:
    void setix(long ix)
    {
        _ix = ix;
    }
    
private:
    long _ix;
};

class B
:virtual public A
{};

class C
:virtual public A
{};

class D
:public B
,public C
{};

此时A、B、C、D类的大小分别为8B、16B、16B、24B。

如何理解?此时B、C类中的16B中的前面8B是指针,后面8B是继承来自A的_ix。D中的24B则是继承了B、C中的指针和一个_ix。

继承不代表公用。A、B、C、D类对象中的_ix不是同一个。

vfptr指针指向的地址不同。

如果B中的_ix设置为10,C中的_ix设置为20,D继承后的_ix看设置顺序。如果先继承B,那就为20。

基类与派生类对象间的转换

“类型适应”是指两种类型之间的关系,说A类适应B类是指A类的对象能直接用于B类对象所能应用的场合。派生类对象可以适用于基类对象。

  • 可以把派生类的对象赋值给基类的对象

    base = derived

  • 可以把基类的引用绑定到派生类的对象

    Base &ref = derived

  • 可以声明基类的指针指向派生类的对象 (向上转型)

    Base *pbase = &derived

也就是说如果函数的形参是基类对象或者基类对象的引用或者基类对象的指针类型,在进行函数调用时,相应的实参可以是派生类对象。

Base base(1);
Derived derived(10, 11);

base = derived;//相当于Base &operator=(const Base &rhs)函数中的形参Base &rhs与实参derived结合
Base &rhs = derived;//引用的底层实现是指针,那么有*rhs = &derived可以实现
Base *pbase = &derived;

需要注意不能从基类向派生类对象进行转换。

derived = base;//error
Derived &rhs = base;//error
Derived *pderived = &base;//error

如果从基类向派生类进行转换的话,会发生错误。

假设可以从基类向派生类进行转换,那么会发生*pbase的内存越界和derived中的数据成员难以赋值的情况。

我们可以将派生类与基类之间的转换的方向分为向上转型和向下转型。

  • 向上转型:从派生类向基类方向进行转换。安全。

  • 向下转型:从基类向派生类方向进行转换。语法层面不支持,可以使用强制转换实现。常用dynamic_cast运算符。

    Derived *pderived = static_cast<Derived *>(&base);
    

    对于“向下转型”有两种情况。

    • 一种是基类指针/引用所指对象是派生类类型的,这种转换是安全的;

      Base *pbase = new Derived;
      Derived *pderived = static_cast<Derived *>(&pbase);//也可以使用dynamic_cast,总之会成功
      
    • 另一种是基类指针所指对象为基类类型,这种转换是不安全的。在这种情况下dynamic_cast在运行时做检查,转换失败,返回结果为0。

      Base *pbase = new Base;
      if(Derived *pderived = dynamic_cast<Derived *>(&pbase))
      {
          cout << "success" << endl;
      }
      else
      {
          cout << "fail" << endl;//转换失败,会返回0
      }
      

      如果是引用类型,与指针不同的是,并不存在空引用,所以dynamic_cast转换失败时会返回bad_cast异常。

派生类对象间的复制控制

从前面的知识,我们知道,基类的拷贝构造函数和operator=运算符函数不能被派生类继承,那么在执行派生类对象间的复制操作时,就需要注意以下几种情况:

  1. 如果用户定义了基类的拷贝构造函数,而没有定义派生类的拷贝构造函数,那么在用一个派生类对象初始化新的派生类对象时,两对象间的派生类部分执行缺省的行为,而两对象间的基类部分执行用户定义的基类拷贝构造函数。

  2. 如果用户重载了基类的赋值运算符函数,而没有重载派生类的赋值运算符函数,那么在用一个派生类对象给另一个已经存在的派生类对象赋值时,两对象间的派生类部分执行缺省的赋值行为,而两对象间的基类部分执行用户定义的重载赋值函数。

  3. 如果用户定义了派生类的拷贝构造函数或者派生类的赋值运算符函数,则在用已有派生类对象初始化新的派生类对象时,或者在派生类对象间赋值时,将会执行用户定义的派生类的拷贝构造函数或者赋值运算符函数,而不会再自动调用基类的拷贝构造函数和基类的赋值运算符函数,这时,通常需要用户在派生类的拷贝构造函数或者派生类的赋值运算符函数中显式调用基类的拷贝构造或赋值运算符函数。

Base
{
public:
    Base();
    Base(const char *pbase);
    Base(const Base &rhs);
    Base &operator=(const Base &rhs);
    ~Base();

    friend std::ostream &operator<<(std::ostream &os, const Base &rhs);
private:
    char *_pbase;
};

class Derived
:public Base
{
public:
    Derived();
    Derived(const char *pderived);
    Derived(const Derived &rhs);
    Derived &operator=(const Derived &rhs);
    ~Derived();

    friend std::ostream &operator<<(std::ostream &os, const Derived &rhs);
private:
    char *_pderived;
};

int main()
{
    Derived derived1("hello", "world");
    cout << "derived1 = " << derived1 << endl;

    cout << endl;
    Derived derived2 = derived1;
    cout << "derived2 = " << derived2 << endl;   

    cout << endl;
    Derived derived3("zhejiang", "hangzhou");
    cout << "derived3 = " << derived3 << endl;

    cout << endl;
    derived3 = derived1;
    cout << "derived3 = " << derived3 << endl;

    cout << endl;
}

此时对应情况3,其中Derived中的拷贝构造函数不会再自动调用基类的拷贝构造函数,那么如果函数如下

Derived::Derived(const Derived &rhs)
: _pderived(new char[strlen(rhs._pderived) + 1]())
{   
    cout << "Derived(const Derived &)" << endl;
    strcpy(_pderived,rhs._pderived);
} 

此时会发现derived2中的基类会为空:

Base(const char)
Derived(const char *, const char *)
derived1 = hello, world

Base()
Derived(const Derived &)
derived2 = , world

因此需要将Derived中的拷贝构造函数写成如下来显式调用基类的拷贝构造函数。

Derived::Derived(const Derived &rhs)
: Base()//显式调用基类的拷贝构造函数
, _pderived(new char[strlen(rhs._pderived) + 1]())
{   
    cout << "Derived(const Derived &)" << endl;
    strcpy(_pderived,rhs._pderived);
} 

同样地,Derived中的赋值运算符函数也具有上面的特性。

Derived::Derived &operator=(const Derived &rhs)
{   
    cout << "Derived &operator=(const Derived &)" << endl;
    if(this != &rhs)
    {
        Base::operator=(rhs);//显式调用基类的赋值运算符函数
        
        delete [] _pderived;
        _pderived = nullptr;

        _pderived = new char[strlen(rhs._pderived) + 1]();
        strcpy(_pderived, rhs._pderived);
    }   
    return *this;
}   

禁止对象的复制/赋值

  1. 将拷贝构造函数与赋值运算函数设置为私有的。(C++98)

  2. 将拷贝构造函数与赋值运算符函数设置为=delete.(C++11)

  3. 设置一个基类,做以下中的一个操作

    • 将拷贝构造函数与赋值运算符函数设置为私有
    • 将拷贝构造函数与赋值运算符函数设置为=delete

    然后让派生类继承,同时派生类不去写拷贝构造函数与赋值运算符函数。(C++98)

class NonCopyable
{
public:
    NonCopyable()
    {}
    
    NonCopyable(const NonCopyable &rhs) = delete;
    NonCopyable &operator=(const NonCopyable &rhs) = delete;
};

class Derived
:public NonCopyable
{
    //...
};

多态、虚函数

多态

多态通常是指对于同一个消息、同一种调用,在不同的场合,针对不同的对象下,执行不同的行为 。

  • 编译时多态:静态多态。函数重载、运算符重载、模板是静态多态,C++编译器根据传递给函数的参数和函数名决定要使用哪一个函数,也称先期联编。
  • 运行时多态:在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供这么一套称为“动态联编”(dynamic binding)的机制,也叫晚期联编。C++通过虚函数来实现动态联编。

虚函数

定义

虚函数指的是在类中被声明为virtual的成员函数。

class A
{
    virtual 返回类型 function(参数列表):
    {
	}
};

虚函数往往会与继承一起使用。如果一个基类的成员函数定义为虚函数,那么么它在所有派生类中也保持为虚函数,即使在派生类中省略了virtual关键字,也仍然是虚函数。

派生类可以对虚函数根据需要进行重写(覆盖,override),重写的格式有一定的要求:

  • 与基类的虚函数有相同的参数个数;
  • 与基类的虚函数有相同的参数类型与顺序;
  • 与基类的虚函数有相同的返回类型。

即除了函数体、this指针不同,其他都相同。

class Base
{
public:
    virtual
    void display()
    {
        cout << "Base::display()" << endl;
    }
    
    void print()
    {
        cout << "Base::print()" << endl;
    }
};

class Derived
:public Base
{
public:
	void display()
    {
        cout << "Derived::display()" << endl;
    }
};

void test(Base *pbase)
{
    pbase->display();
}

int main()
{
    Base base;
    Derived derived;
    test(&base);
    test(&derived);
    
    return 0;
}

结果为

Base::display()
Derived::display()

从实现来看,test(&derived)中的指针pbase只能调用Base类的display函数,但最终的结果是调用了Derived类中的display函数。这是如何实现的呢?

实现机制★

虚函数的实现是怎样的呢?简单来说,就是通过一张虚函数表实现的。

具体地讲,当类中定义了一个虚函数后,会在该类创建的对象的存储布局的开始位置多一个虚函数指针(vfptr),该虚函数指针指向了一张虚函数表,而该虚函数表就像一个数组,表中存放的就是各虚函数的入口地址。

屏幕截图 2024-02-22 172826

当一个基类中设有虚函数,而一个派生类继承了该基类,并对虚函数进行了重定义,我们称之为覆盖(override),指的是派生类的虚函数表中相应虚函数的入口地址被覆盖。

动态多态被激活的条件★

需要满足以下五个条件:

  1. 基类定义虚函数
  2. 派生类覆盖(重写)虚函数
  3. 创建派生类对象
  4. 基类的指针指向派生类对象
  5. 基类指针调用虚函数

哪些函数不能被设置为虚函数?

  • 普通函数:因为虚函数必须是成员函数,而普通函数是非成员函数,与定义不符。
  • 静态成员函数:因为静态成员函数中没有this指针,就不好指向对象,那也就不好指向虚函数指针。静态成员函数是被该类的所有对象共享的。静态函数在调用的时候在编译的时候,而虚函数要想体现多态发生的时机在运行的时候。
  • 内联成员函数:内联函数发生的时机在编译的时候,虚函数要想体现多态,发生的时机在运行的时候。如果将内联成员函数设置为虚函数,编译器会删去内联的特点,不会报错,但是此时已经失去了内联的含义。
  • 友元普通函数:如果友元函数本身是普通函数,那么是不能设置为虚函数的。但是如果友元函数是成员函数的形式,那么可以设置为虚函数。
  • 构造函数:1)构造函数在调用的时候在编译的时候,而虚函数要想体现多态发生的时机在运行的时候。2)构造函数不能被继承,但是虚函数是可以被继承的(只能作为参考点,因为析构函数不能被继承,但是也能作为虚函数)。3)构造函数如果是虚函数,那么需要通过虚函数指针进行调用,而虚函数指针是存在对象布局的前面,但是如果构造函数不调用,那么对象的内存布局就是不完整的,此时有没有虚函数指针还不一定。

虚函数的访问

指针访问

呈现了动态联编的特点。

  • 使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数,而不是根据指针指向的对象类型;
  • 使用指针访问虚函数时,编译器根据指针所指对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关。

引用访问

使用引用访问虚函数,可以将引用理解成一种“受限制的指针”,因此与使用指针访问虚函数类似,使用动态联编,表现出动态多态特性。

对象访问

和普通函数一样,虚函数一样可以通过对象名来调用,在编译的时候就能确定调用的是哪个对象的虚函数,此时编译器采用的是静态联编,与动态多态无关。

class Base
{
    //...
};

class Derived
: public Base
{
    //...
};

base.print();
derived.print();//和动态多态无关

通过对象名访问虚函数时, 调用哪个类的函数取决于定义对象名的类型。对象类型是基类时,就调用基类的函数;对象类型是子类时,就调用子类的函数。

成员函数中访问

在类内的成员函数中访问该类中的虚函数,采用动态联编,需要使用this指针。

class Base
{
    //...    
    void func1()
    {
        print();
    }
    
    void func2()
    {
        Base::print();
    }
    //...
};

class Derived
: public Base
{
    //...
};

int main()
{
    Derived derived;
    Base *pbase = &derived;
    
    pbase->func1();
    pbase->func2();
    return 0;
}

此时的func1()func2()本质不同,func1()中暗含了一个this指针(类型是Base * const this),此时this指针指向派生类对象,基类指针调用虚函数,满足了动态多态激活的五个条件。因此,第一个调用了Derived::print,第二个调用了Base::print()

构造函数/析构函数中访问

构造函数和析构函数是特殊的成员函数,在其中访问虚函数时,C++采用静态联编,即在构造函数或析构函数内,即使是使用“this->虚函数名”的形式来调用,编译器仍将其解释为静态联编的“本类名::虚函数名”。即它们所调用的虚函数是自己类中定义的函数,如果在自己的类中没有实现该函数,则调用的是基类中的虚函数。但绝不会调用任何在派生类中覆盖的虚函数。不满足5条要求中的第4条。

Q:虚函数与动态多态是不是等价的?

A:动态多态必须需要虚函数,但是有虚函数不一定能体现动态多态,动态多态等价的条件是五个条件。

抽象类

纯虚函数

纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。声明纯虚函数的目的在于,提供一个与派生类一致的接口。

class 类名
{
public:
    virtual 返回类型 函数名(参数列表) = 0;
};

设置纯虚函数的意义,就是让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。

class Base
{
public:
    virtual void display() const = 0;
};

class Derived
: public Base
{
public:
    virtual void display() const override//override关键字表明是进行覆盖,函数如果不覆盖虚函数就会出错。
    {
    	cout << "Derived::display(int)" << endl;
    }
};

此时有以下函数

int main()
{
    Derived derived;
    derived.display();
    
    Base *pbase;//可以创建抽象类的指针,可以用派生类进行初始化 Base *pbase = &derived;
    Base base;//error
    pbase->display();
}
  • 声明了纯虚函数的类称为抽象类
  • 抽象类的派生类中,如果也有纯虚函数没有实现,那么抽象类的派生类也是抽象类

抽象类是不能创建对象的,但也可以创建抽象类的指针或者引用。

构造函数用protected修饰

使用protected修饰构造函数的类是抽象类。

class Base
{
protected:
    Base()
    {
        cout << "Base()" << endl;
    }
};

class Derived
: public Base
{
public:
    Derived()
    : Base()
    {
        cout << "Derived()" << cout;
    }
};

构造函数使用protected进行修饰,此时构造函数可以被派生类访问,但是不可以由对象进行访问,因此此时也是抽象类。

虚析构函数★

虽然构造函数不能被定义成虚函数,但析构函数可以定义为虚函数。

如果有一个基类的指针指向派生类的对象,并且想通过该指针delete派生类对象,系统将只会执行基类的析构函数,而不会执行派生类的析构函数,会产生内存泄露的问题。

那么我们想要解决此问题,可以将析构函数定义为虚析构函数,系统将先执行派生类对象的析构函数,然后再执行基类的析构函数。

也可以使用向下转型,将指针的类型进行转换,保证执行的析构函数是派生类的析构函数。

**如果基类的析构函数声明为虚的,派生类的析构函数也将自动成为虚析构函数,无论派生类析构函数声明中是否加virtual关键字。**即使基类的析构函数~Base()和派生类中的析构函数~Derived()的名字不同,我们也认为这是一种覆盖(C++中唯一的函数名字不一样的覆盖)。

原因是对于任何一个类而言,析构函数是唯一的,不能重载。对于编译器而言,只要类定义了析构函数,将其改名为~destructor(),此时名字相同,这时就是一个覆盖。

重载、隐藏、覆盖★

  • 重载(overload):发生在同一个作用域中,函数名称相同,但参数的类型、个数、顺序不同(参数列表不一样)。

  • 覆盖(重写,override):发生在基类与派生类中,同名虚函数,参数列表亦完全相同。

  • 隐藏(重定义):发生在基类与派生类中,指的是在某些情况下,派生类中的函数屏蔽了基类中的同名函数(同名数据成员也有隐藏)。

    1. 如果派生类的函数与基类的函数同名,但是参数不同。那么此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。

      class Base
      {
      public:
          void print();
      };
      
      class Derived
      : public Base
      {
          void print(int x);
      };
      

      此时Derived类对象不能再使用Derived.print(),但是因为是隐藏而非删除,因此还可以用作用域限定符来调用Derived.Base::print()

    2. 如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。那么此时,基类的函数被隐藏(注意别与覆盖混淆)。

      class Base
      {
      public:
          void print();
      };
      
      class Derived
      : public Base
      {
          void print();
      };
      

      此时调用Derived.print()中的print()函数在Derived类中。

    3. 数据成员也存在隐藏。

验证虚表的存在

#include <iostream>

using std::cout;
using std::endl;

class Base
{
public:
    Base(long base = 0)
    : _base(base)
    {
        cout << "Base(long  = 0)" << endl;
    }

    ~Base()
    {
        cout << "~Base()" <<  endl;
    }

    virtual
    void func()
    {
        cout << "virtual void Base::func()" << endl;
    }

    virtual
    void gunc()
    {
        cout << "virtual void Base::gunc()" << endl;
    }

    virtual
    void hunc()
    {
        cout << "virtual void Base::hunc()" << endl;
    }
private:
    long _base;
};

class Derived
: public Base
{
public:
    Derived(long base = 0, long derived = 0)
    : Base(base)
    , _derived(derived)
    {
        cout << "Derived(long  = 0, long = 0)" << endl;
    }

    ~Derived()
    {
        cout << "~Derived()" <<  endl;
    }

    virtual
    void func()
    {
        cout << "virtual void Derived::func()" << endl;
    }

    virtual
    void gunc()
    {
        cout << "virtual void Derived::gunc()" << endl;
    }

    virtual
    void hunc()
    {
        cout << "virtual void Derived::hunc()" << endl;
    }
private:
    long _derived;
};

void test()
{
    //虚表是存在的,位于只读段,普通的单继承而言虚表就是唯一的
    cout <<"sizeof(Derived) = " << sizeof(Derived) << endl;
    cout <<endl;
    Derived d(10, 20);//栈对象
    printf("打印对象d的地址 : %p\n", &d);
    printf("打印对象d的地址 : %p\n", (long *)&d);
    printf("打印虚表的地址 : %p\n", (long *)*(long *)&d);
    printf("第一个虚函数的入口地址: %p\n", 
           (long *)*(long *)*(long *)&d);

    cout << endl;
    Derived d2(100, 200);//栈对象
    printf("打印对象d2的地址 : %p\n", &d2);
    printf("打印对象d2的地址 : %p\n", (long *)&d2);
    printf("打印虚表的地址 : %p\n", (long *)*(long *)&d2);
    printf("第一个虚函数的入口地址: %p\n", 
           (long *)*(long *)*(long *)&d2);

    cout << endl;


    typedef void (*pFunc)();
    /* typedef void (*)() pFunc;//语法上不支持,error */
    /* typedef int A;//ok */
    /* void (*pFunc)(); */
    pFunc f = (pFunc)*((long *)*(long *)&d);
    printf("打印第一个虚函数的地址: %p\n", f);
    f();//执行了第一个虚函数

    cout << endl;
    pFunc f2 = (pFunc)*((long *)*(long *)&d + 1);
    printf("打印第二个虚函数的地址: %p\n", f2);
    f2();//执行了第二个虚函数

    cout << endl;
    pFunc f3 = (pFunc)*((long *)*(long *)&d + 2);
    printf("打印第三个虚函数的地址: %p\n", f3);
    f3();//执行了第三个虚函数


    cout << endl << endl;
    cout << "_base = " << (long )*((long *)&d + 1) << endl;
    cout << "_derived = " << (long )*((long *)&d + 2) << endl;

}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

结果是

sizeof(Derived) = 24

Base(long  = 0)
Derived(long  = 0, long = 0)
打印对象d的地址 : 0x7ffded615580
打印对象d的地址 : 0x7ffded615580
打印虚表的地址 : 0x556baeb53cf0//虚表存在,位于只读段
第一个虚函数的入口地址: 0x556bae95329a

Base(long  = 0)
Derived(long  = 0, long = 0)
打印对象d2的地址 : 0x7ffded6155a0
打印对象d2的地址 : 0x7ffded6155a0
打印虚表的地址 : 0x556baeb53cf0
第一个虚函数的入口地址: 0x556bae95329a

打印第一个虚函数的地址: 0x556bae95329a
virtual void Derived::func()

打印第二个虚函数的地址: 0x556bae9532d2
virtual void Derived::gunc()

打印第三个虚函数的地址: 0x556bae95330a
virtual void Derived::hunc()


_base = 10
_derived = 20
~Derived()
~Base()
~Derived()
~Base()

代码中的typedef void (*pFunc)();相当于typedef void (*)() pFunc;(C++后者不支持),其实指的是将void (*)()这一种函数指针取了一个别名为pFunc。

一般地,typedef作用于声明变量的结果可以拆分成两步。

  1. void (*pFunc)()声明了一个函数指针,变量名为pFunc,类型为void (*)();
  2. 将typedef作用于void (*pFunc)(),将声明变量得到的变量名提升为类型,此时pFunc成为了一种类型,表示void (*)()。

pFunc f = (pFunc)*((long *)*(long *)&d);此时后面的*((long *)*(long *)&d)表示第一个虚函数的入口地址,那么我们可以用(pFunc)对虚函数的类型进行强制转换,此时因为类型是void (*)(),因此大小为8B。

根据上面的例子,虚表是存在的,位于只读段。对于普通的单继承,虚表是唯一的。

可以用下面的图进行理解:

屏幕截图 2024-02-23 171835

带虚函数的多基派生的二义性、虚拟继承

带虚函数的多基派生的二义性

有如下代码

#include <iostream>

using std::cout;
using std::endl;

class Base1
{
public:
    virtual 
    void a()
    {
        cout << "virtual void Base1::a()" << endl;
    }

    virtual 
    void b()
    {
        cout << "virtual void Base1::b()" << endl;
    }

    virtual 
    void c()
    {
        cout << "virtual void Base1::c()" << endl;
    }
};

class Base2
{
public:
    virtual 
    void a()
    {
        cout << "virtual void Base2::a()" << endl;
    }

    virtual 
    void b()
    {
        cout << "virtual void Base2::b()" << endl;
    }

    void c()
    {
        cout << "void Base2::c()" << endl;
    }

    void d()
    {
        cout << "void Base2::d()" << endl;
    }
};

class Derived
: public Base1
, public Base2
{
public:
    virtual
    void a()
    {
        cout << "virtual void Derived::a()" << endl;
    }

    void c()//虚函数
    {
        cout << "void Derived::c()" << endl;
    }

    void d()
    {
        cout << "void Derived::d()" << endl;
    }
};
//判断一个函数是不是虚函数,可以再继承一次,构建动态多态被激活的五条件
class Derived2
: public Derived
{
public:
    void c()//不知道该函数是虚函数还是非虚函数?
    {
        cout << "void Derived2::c()" << endl;
    }
};

void test()
{
    cout << "sizeof(Base1) = " << sizeof(Base1) << endl;
    cout << "sizeof(Base2) = " << sizeof(Base2) << endl;
    cout << "sizeof(Derived) = " << sizeof(Derived) << endl;

    cout <<endl;
    Derived derived;
    printf("&derived = %p\n", &derived);

    cout << endl;
    Base1 *pbase1 = &derived;
    printf("pbase1 = %p\n", pbase1);
    pbase1->a();//Derived::a()
    pbase1->b();//Base1::a()
    pbase1->c();//Derived::c()

    cout << endl;
    Base2 *pbase2 = &derived;
    printf("pbase2 = %p\n", pbase2);
    pbase2->a();//Derived::a()
    pbase2->b();//Base2::b()
    pbase2->c();//Base2::c()
    pbase2->d();//Base2::d()

    cout << endl;
    Derived *pderived = &derived;
    printf("pderived = %p\n", pderived);
    pderived->a();//Derived::a()
    /* pderived->b();//error, b函数具备二义性 */
    pderived->Base1::b();//Base1::b()
    pderived->Base2::b();//Base2::b()
    pderived->c();//Derived::c()
    pderived->d();//Derived::d()

    cout << endl;
    Derived2 d2;
    Derived *pd = &d2;
    pd->c();//Derived2::c(),说明c()在Derived中是虚函数,那么说明如果继承的时候既有虚函数,又有非虚函数,会选择虚函数
}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

结果为

sizeof(Base1) = 8
sizeof(Base2) = 8
sizeof(Derived) = 16

&derived = 0x7ffd551d1bc0

pbase1 = 0x7ffd551d1bc0
virtual void Derived::a()
virtual void Base1::b()
void Derived::c()

pbase2 = 0x7ffd551d1bc8
virtual void Derived::a()
virtual void Base2::b()
void Base2::c()
void Base2::d()

pderived = 0x7ffd551d1bc0
virtual void Derived::a()
virtual void Base1::b()
virtual void Base2::b()
void Derived::c()
void Derived::d()

void Derived2::c()

可以发现Base1、Base2和Derived的大小分别为8B、8B和16B。此外,在进行Base2 *pbase2 = &derived;后,这里指向的是Derived的第二个虚表。

这里Base1中没有数据成员,说明pbase2的在pbase1后面8B。

如果Base1中有一个long型数据成员,那么会是16B。

可以参考下图:

image-20240117151819320

虚拟继承

虚拟继承的特征

virtual在词典中的解释是:

  1. Existing or resulting in essence or effect though not in actual fact, form, or name.实质上的,实际上的:虽然没有实际的事实、形式或名义,但在实际上或效果上存在或产生的;
  2. Existing in the mind, especially as a product of the imagination. Used in literary criticism of text. 虚的,内心的:在头脑中存在的,尤指意想的产物,用于文学批评中。

C++中的virtual关键字采用第一个定义。因此关键在于存在间接共享三个特征。

  • 虚函数是存在的
  • 虚函数必须要通过一种间接的运行时(而不是编译时)机制才能够激活(调用)的函数
  • 共享性表现在基类会共享被派生类重定义后的虚函数

虚拟继承又是如何表现这三种特征的呢?

  • 存在即表示虚继承体系和虚基类确实存在
  • 间接性表现在当访问虚基类的成员时同样也必须通过某种间接机制来完成(通过虚基表来完成)
  • 共享性表现在虚基类会在虚继承体系中被共享,而不会出现多份拷贝

虚拟继承的形式

语法格式如下:

class Base;

class Derived
: virtual public/protected/private Base
{
    //...
};

虚拟继承时派生类对象的构造和析构

在普通的继承体系中,比如A派生出B,B派生出C,则创建C对象时,在C类构造函数的初始化列表中调用B类构造函数,然后在B类构造函数初始化列表中调用A类的构造函数,即可完成对象的创建操作。但在虚拟继承中,则有所不同。

在 C++ 中,如果继承链上存在虚继承的基类,则最底层的子类要负责完成该虚基类部分成员的构造。即我们需要显式调用虚基类的构造函数来完成初始化,如果不显式调用,则编译器会调用虚基类的缺省构造函数,不管初始化列表中次序如何,对虚基类构造函数的调用总是先于普通基类的构造函数。如果虚基类中没有定义的缺省构造函数,则会编译错误。因为如果不这样做,虚基类部分会在存在的多个继承链上被多次初始化。

虚基类在虚继承体系中只有一份,所以可能会被多次初始化。

很多时候,对于继承链上的中间类,我们也会在其构造函数中显式调用虚基类的构造函数,因为一旦有人要创建这些中间类的对象,我们要保证它们能够得到正确的初始化。

内存布局图

可以用Visual Studio工具来生成内存布局图。

1. 单继承

比如对一个最普通的基类A与继承类B,此时有

#include <iostream>
using std::cout;
using std::endl;

class A
{
public:
	A() : _ia(10) {}

	void f()
	{
		cout << "A::f()" << endl;
	}
private:
	int _ia;
};

class B
: public A
{
public:
	B() : _ib(20) {}

	void fb()
	{
		cout << "A::fb()" << endl;
	}

	void f()
	{
		cout << "B::f()" << endl;
	}

	void fb2()
	{
		cout << "B::fb2()" << endl;
	}

private:
	int _ib;
};

int main(void)
{
	cout << sizeof(A) << endl;
	cout << sizeof(B) << endl;
	B b;
	return 0;
}

此时生成的类B的内存分布为

1>class B	size(8):
1>	+---
1> 0	| +--- (base class A)
1> 0	| | _ia
1>	| +---
1> 4	| _ib

即下图

image-20240225000016722

同理,在Visual Studio中进行生成解决方案,把生成的内存分布画图,有:

image-20240225000055161

image-20240225000109449

image-20240225000118908

image-20240225000218585

image-20240225000229554

如图,对于单继承有以下规律:

  • 如果有虚拟继承,那就有虚指针vbptr,指向虚基表。虚基表存放着偏移信息。
  • 如果有虚函数,那就有虚函数指针vfptr,指向虚函数表。虚函数表存放着虚函数的入口地址。
  • 如果有虚拟继承,派生类中的成员就会提前,基类(包括虚函数指针)都会后移。
  • 自己新增虚函数会新增一个指针和虚表,目的是用空间换时间,减少时间开销。

多重继承

对于带虚函数、新增虚函数、多重继承的派生类有继承图:

image-20240225001545592

image-20240225001745486

image-20240225001755751

仔细观察上面的3个图的栈区和新增虚函数的情况。

  • 每个基类都有自己的虚函数表
  • 派生类如果有自己新增的虚函数,会被加入到第一个虚函数表之中
  • 内存布局中,其基类的布局按照基类被继承时的顺序进行排列
  • 派生类会覆盖基类的虚函数,只有第一个虚函数表中存放的是真实的被覆盖的虚函数的地址;其它的虚函数表中存放的并不是真实的对应的虚函数的地址,而只是一条跳转指令

菱形继承

如果不是虚拟继承,会出现存储二义性。

image-20240225002902131

可以进行虚拟继承解决此问题:

image-20240225002915792

虚基指针所指向的虚基表的内容:

  • 虚基指针的第一条内容表示的是该虚基指针距离所在的子对象(*pb1,*pb2)的首地址的偏移
  • 虚基指针的第二条内容表示的是该虚基指针距离虚基类子对象(*pb)的首地址的偏移

阅读第三方库、左值和右值、移动语义与智能指针

阅读第三方库

方法一

  1. 下载第三方库,找到头文件、实现文件和测试文件;
  2. 在测试文件中找到main函数,依照main函数中调用的函数来研究第三方库是如何运作的,研究其中的步骤和原理;
  3. 在研究的过程中,最好多加测试;
  4. 测试完毕后,需要做一下笔记加深记忆。

方法二

  1. 下载第三方库,找到头文件、实现文件和测试文件;
  2. 在测试文件中找到main函数,依照main函数中调用的函数来研究第三方库是如何运作的,研究其中的步骤和原理;
  3. 使用调试工具如gdb将代码走读、调试来看代码如何运行。

方法三

  • 在百度、谷歌上查找第三方库的使用方法。

尝试一下tinyxml2库的使用,对RSS文件进行解析。

左值与右值

对于我们自我实现的String类,有如下主函数

int main()
{
    String s1("hello");
    cout << "s1 = " << s1 << endl;

    cout << endl;
    String s2 = s1; 
    cout << "s1 = " << s1 << endl;
    cout << "s2 = " << s2 << endl;

    cout << endl;
    String s3 = "world";
    cout << "s3 = " << s3 << endl;

    return 0;                                                                                          
}

如果不让编译器进行优化,可以进行 编译g++ xxx.cc -fno-elide-constructors,此时有如下结果

lizi@virtual:CppBaseDay19$./a.out 
String(const char *)
s1 = hello

String(const String &)
s1 = hello
s2 = hello

String(const char *)
String(const String &)
~String()
s3 = world
~String()
~String()
~String()

在10-11行,这里临时生成了一个对象,随后马上销毁,说明这里进行了隐式转换,"world"其实是String("world")。然后s3要再申请一块堆空间用于深拷贝String("world")。但是这样会使得内存产生很多碎片,那我们应该如何优化呢?

什么是左值与右值?

**左值:**可以取地址的就是左值。

**右值:**不可以取地址的就是右值,包含:临时变量、临时对象、匿名对象、字面值常量。

例:

int a = 3;
int b = 4;

&a;//左值
&b;//左值
&(a+b);//error,右值

左值与右值的性质

  • 非const左值引用不能绑定到右值。非const左值可以识别左值,但无法识别右值。

    int number = 10;
    
    int &ref = number;//ok
    int &ref2 = 10;//error
    
  • const左值引用既可以绑定到左值,也可以绑定到右值。const左值无法区分绑定的对象是左值还是右值。

    int number = 10;
    
    const int &ref = number;//ok
    const int &ref2 = 10;//ok
    
  • 右值引用可以绑定到右值,不能绑定到左值。右值引用可以识别右值,但是识别不了左值。(C++11后)

    int number = 10;
    
    int &&ref = 10;//ok
    int &&ref = number;//error
    

右值引用

特点

右值引用可以绑定到右值,可以识别右值,右值引用不能绑定到左值,也就是不能识别左值。

右值引用是左值还是右值?

这个与右值引用本身有没有名字有关,如果是int &&rref = 10,右值引用本身就是左值,因为有名字。

如果右值引用本身没有名字,那右值引用就是右值,如右值引用作为函数返回值。

int &&funC()
{
    return 10;
}
用右值引用来优化String类的代码

我们的目的是将String s3 = "world"这个代码进行优化。

将String类增加一个函数如下:

String(String &&rhs)
: _pstr(rhs._pstr)
{
    cout << "String(String &&)" << endl;
    rhs._pstr = nullptr;
}

此时的执行后的结果为

String(const char *)
String(String &&)
~String()
s3 = world
~String()

这个函数进行的是浅拷贝,将String("world")申请的堆空间直接转交给s3来控制,而其本来的指针指向nullptr。这样可以少申请一块堆空间。

这里增加的这个函数称为移动构造函数。

移动语义

移动构造函数

如上,以右值引用作为参数的构造函数。

 String(String &&rhs)
: _pstr(rhs._pstr)
{
    cout << "String(String &&)" << endl;
    rhs._pstr = nullptr;
}

移动赋值运算符函数

String &operator=(String &&rhs)
{
    cout << "String &operator=(String &&)" << endl;
    if(this != &rhs)//没有这个判断条件s2 = std::move(s2);(此行为称为自移动)会出错
    {
        delete [] _pstr;
        _pstr = nullptr;
        
        _pstr = rhs._pstr;
        rhs._pstr = nullptr;
    }
    return *this;
}

此时执行

s2 = "world";

后,结果如下

String(const char *)
String &operator=(String &&)
~String()
s2 = world
~String()

此外,移动函数有一个特点:具有移动语义的函数(移动构造函数和移动赋值运算符函数)优先于具有复制语义的函数(拷贝构造函数和赋值运算符函数)的执行。

std::move函数

将左值转化为右值,在内部其实是做了一个强制转换static_cast<T &&>(lvalue)

资源管理与智能指针

C语言的难点

C语言对于资源管理的时候,比如文件指针,由于分支较多,用于释放资源的代码需要在不同的位置重复书写多次。如果再加入异常处理,fclose(f)情况会变得更加复杂。

void UseFile(char const *fn)
{
    FILE *f = fopen(fn, "r");
    if(!g())
    {
        fclose(f);
        return;
    }
    if(!h())
    {
        fclose(f);
        return;
    }
    //...
    fclose();
}

在C++中可以用构造/析构函数来管理文件指针。

#include <iostream>
#include <string>

using std::cout;
using std::endl;
using std::string;

class SafeFile
{
public:
    //构造函数初始化资源
    SafeFile(FILE *fp)
    : _fp(fp)
    {
        cout << "SafeFile(FILE *)" <<endl;
        if(nullptr == _fp)
        {
            cout << "nullptr == _fp" << endl;
        }
    }

    //提供若干访问资源的方法
    void write(const string &msg)
    {
        fwrite(msg.c_str(), msg.size(), 1, _fp);
    }

    //析构函数释放资源
    ~SafeFile()
    {
        cout <<"~SafeFile()" << endl;
        if(_fp)
        {
            fclose(_fp);
            cout << "fclose(_fp)" << endl;
        }
    }
private:
    FILE *_fp;
};

void test()
{
    string msg = "hello,world\n";
    SafeFile sf(fopen("wd.txt", "a+"));//sf栈对象
    sf.write(msg);

}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

利用了栈对象的生命周期管理资源。当栈对象在离开作用域的时候,会执行析构函数以进行回收资源。

RAII技术

RAII(资源的获取即初始化,Resource Acquisition Is Initialization)是一种由 C++创造者 Bjarne Stroustrup 提出的, 利用栈对象生命周期管理程序资源(包括内存、文件句柄、锁等)的技术。

关键在于保证资源的释放顺序与获取顺序严格相反,这正好是构造函数与析构函数的作用。

RAII常见特征:

  • 在构造函数中初始化资源或者托管资源;
  • 在析构函数中释放资源(回收资源);
  • 提供若干访问资源的方法;
  • 对象语义的情况下一般不允许复制或者赋值。

区分值语义与对象语义:

  1. 值语义:可以执行复制和赋值。
  2. 对象语义:不可以进行复制和赋值。有以下两种方法:
    1. 将拷贝构造函数和赋值运算符函数设置为私有;
    2. 将拷贝构造函数和赋值运算符函数使用=delete。
 #include <iostream>

using std::cout;
using std::endl;

template <typename T>
class RAII
{
public:
    //1. 在构造函数中初始化资源
    RAII(T *pdata)
    : _pdata(pdata)
    {
        cout << "RAII(T *)" << endl;
    }

    //2. 在析构函数中释放资源
    //认为资源是new出来的
    ~RAII()
    {
        cout << "~RAII()" << endl;
        if(_pdata)
        {
            delete _pdata;
            _pdata = nullptr;
        }
    }

    //3. 提供若访问资源的方法
    T *operator->()
    {
        return _pdata;
    }

    T &operator*()
    {
        return *_pdata;
    }

    T *get() const
    {
        return _pdata;
    }

    void reset(T *pdata)
    {
        if(_pdata)
        {
            delete _pdata;
            _pdata = nullptr;
        }
        _pdata = pdata;
    }

    //4. 一般不允许复制或者赋值
    RAII(const RAII &rhs) = delete;
    RAII &operator=(const RAII &rhs) = delete;
private:
    T *_pdata;
};

class Point
{
public:
    Point(int ix = 0, int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {
        cout << "Point(int = 0, int = 0)" << endl;
    }

    void print() const
    {
        cout << "(" << _ix 
             << ", " << _iy
             << ")" << endl;
    }

    ~Point()
    {
        cout << "~Point()" << endl;
    }
private:
    int _ix;
    int _iy;
};

void test()
{
    RAII<int> pInt(new int(10));//pInt的生命周期达到的时候

    RAII<Point> pt(new Point(1, 2));//pt是栈对象
    pt->print();//pt可以像指针一样进行使用
    (*pt).print();//pt看成是一种智能指针

    cout << endl <<"====指行reset====" <<endl;
    pt.reset(new Point(4, 5));
    pt->print();

}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

利用pt这个栈对象的生命周期管理了堆空间资源,然后当pt的生命周期达到的时候,会执行pt的析构函数,也就是RAII的析构函数,回收托管的资源(堆空间资源)。

四种智能指针

RAII的对象有智能指针的雏形。

auto_ptr
void test()
{
    int *pt = new int(10);
    auto_ptr<int> ap(pt);
    
    cout << "ap = " << ap << endl;
    cout << "ap.get() = " << ap.get() << endl;
    cout << "pt = " << pt << endl << endl;
    
    auto_ptr<int> ap2(ap);
    cout << "*ap2 = " << *ap2 << endl;
    cout << "*ap = " << ap << endl; //error,Segmentation falut
}

第12行会出现栈错误,说明auto_ptr具有缺陷。查看源代码进行理解:

template <class _Tp> 
class auto_ptr 
{
private:
  _Tp* _M_ptr;

public:
   //auto_ptr<int> ap2 = ap;
   auto_ptr(auto_ptr& __a)  
  : _M_ptr(__a.release()) 
  {

  }
    
    _Tp* release() 
    {
        _Tp* __tmp = _M_ptr;
        _M_ptr = nullptr;//ap在此时被设置为nullptr
        return __tmp;
  }
};

//在执行拷贝构造函数的时候,将ap托管的空间的所有权转移给了ap2,ap的数据成员被设置为nullptr。

auto_ptr的缺陷在于:复制 auto_ptr,会复制指针并转移所有权给目标;auto_ptr 的复制构造和复制赋值都会修改其右侧参数,而且“副本”不等于原值。因为这些不常见的复制语义,不可将auto_ptr置于标准容器中。

auto_ptr在C++11中弃用,C++17中移除。

unique_ptr
特点

std::unique_ptr 是通过指针占有并管理另一对象,并在 unique_ptr 离开作用域时释放该对象的智能指针。

占有:独享所有权,即对于托管的空间是独享的。

不能进行复制/赋值
unique_ptr<int> up(new int(100));

unique_ptr<int> up2 = up;//error 不能赋值
up3 = up;//error 不能复制
可作为容器元素

具有移动(std::move)语义,可以传入右值以作为容器元素。

vector<unique_ptr<int>> vec;

unique_ptr<int> up(new int(100));
vec.push_back(up);//error 此时会调用拷贝构造函数,从上面拷贝到vector中,但是拷贝构造用函数=delete
vec.push_back(std::move(up));

vec.push_back(unique_ptr<int> up(new int(200)));//直接传入右值也可以
shared_ptr★
特点

std::shared_ptr 是通过指针保持对象共享所有权的智能指针。多个 shared_ptr 对象可占有同一对象。

使用了引用计数的技术:

  • 引进了一个计数器shared_count,用来表示当前有多少个智能指针对象共享指针指向的内存块

  • 析构函数中不是直接释放指针对应的内存块,如果shared_count大于0则不释放内存只是将引用计数减1,只有计数等于0时释放内存

  • 复制构造与赋值操作符只是提供一般意义上的复制功能,并且将引用计数加1。

可以进行复制与赋值

shared_ptr 的所有特化满足可复制构造 (CopyConstructible)可复制赋值 (CopyAssignable)可小于比较 (LessThanComparable) 的要求并可按语境转换bool

shared_ptr<Point> sp(new Point(1, 2));
shared_ptr<Point> sp2 = sp;//ok 可以赋值

shared_ptr<Point> sp3;
sp3 = sp;//ok 可以复制
可作为容器元素

作为容器的时候,既可以传递左值,也可以传递右值。

shared_ptr也具有移动语义。

vector<shared_ptr<Point>> vec;
vec.push_back(sp);//传递左值
vec.push_back(std::move(sp));//传递右值
循环引用问题
#include <iostream>
#include <memory>

using std::cout;
using std::endl;
using std::shared_ptr;

class Child;//前向声明

class Parent
{
public:
    Parent()
    {
        cout << "Parent()" << endl;
    }

    ~Parent()
    {
        cout << "~Parent()" << endl;
    }

    shared_ptr<Child> _pChild;
};

class Child
{
public:
    Child()
    {
        cout << "Child()" << endl;
    }

    ~Child()
    {
        cout << "~Child()" << endl;
    }
    shared_ptr<Parent> _pParent;
};

void test()
{
    shared_ptr<Parent> spParent(new Parent());//栈对象
    shared_ptr<Child> spChild(new Child());//栈对象
    cout << "spParent.use_count() = " << spParent.use_count() << endl;
    cout << "spChild.use_count() = " << spChild.use_count() << endl;

    cout << endl;
    spParent->_pChild = spChild;//赋值
    spChild->_pParent = spParent;//赋值
    cout << "spParent.use_count() = " << spParent.use_count() << endl;
    cout << "spChild.use_count() = " << spChild.use_count() << endl;
}

int main(int argc, char *argv[])
{
    test();
    return 0;
}

此时执行上面的函数,有

Parent()
Child()
spParent.use_count() = 1
spChild.use_count() = 1
    
spParent.use_count() = 2
spChild.use_count() = 2
//没有析构函数

在栈对象spParent和spChild生命周期结束时,会将其指向堆空间中的Parent和Child的引用计数各减一,但是此时因为智能指针互指,导致两者死锁,导致引用计数仍然不是0,不会执行析构函数,从而导致内存泄露。

解决循环引用的方法之一是将其中一个改为weak_ptr,不会增加引用计数,这样可以使用对象的销毁而打破引用计数减为0的问题。

weak_ptr
特点

std::weak_ptr 是一种智能指针,它对被 std::shared_ptr 管理的对象存在非拥有性(「弱」)引用。在访问所引用的对象前必须先转换为 std::shared_ptr

std::weak_ptr 用来表达临时所有权的概念:当某个对象只有存在时才需要被访问,而且随时可能被他人删除时,可以使用 std::weak_ptr 来跟踪该对象。需要获得临时所有权时,则将其转换为 std::shared_ptr,此时如果原来的 std::shared_ptr 被销毁,则该对象的生命期将被延长至这个临时的 std::shared_ptr 同样被销毁为止。

std::weak_ptr 的另一用法是打断 std::shared_ptr 所管理的对象组成的环状引用。若这种环被孤立(例如无指向环中的外部共享指针),则 shared_ptr 引用计数无法抵达零,而内存被泄露。能令环中的指针之一为弱指针以避免此情况。

解决循环计数

把上面的Child类中的shared_ptr改为weak_ptr。

class Child
{
    //...
    weak_ptr<Parent> _wParent;
};

此时有

spParent->_pChild = spChild;//sp = sp
spChild->_wParent = spParent;//wp = sp

image-20240118173012349

此时weak_ptr指向不会增加shared_ptr的引用计数,执行后的结果为

Parent()
Child()
spParent.use_count() = 1
spChild.use_count() = 1
    
spParent.use_count() = 1
spChild.use_count() = 2
~Parent()//Parent先被析构★
~Child()

由于spParent.use_count() = 1,因此在栈对象被析构后,引用计数为0,而spChild.use_count() = 2,所以引用计数为1。所以Parent对象会被先析构,随后Parent对象中的shared_ptr指针取消,spChild的引用计数会是0,此时Child对象再被析构。

观察器

如何查看weak_ptr指向的空间是否还在?因为weak_ptr无法直接进行访问,必须先提升为shared_ptr先,所以有以下这些方法:

lock函数

创建新的 std::shared_ptr 对象,它共享被管理对象的所有权。若无被管理对象,即 *this 为空,则返回亦为空的 shared_ptr

shared_ptr<int> sp = wp.lock();
cout << "wp.use_count() = " << wp.use_count() << endl;

if(sp)//sp是一个栈对象,观察shared_ptr中的函数,有类型转换函数operator bool,因此可以作为if语句的判断条件
{
    cout << "wp托管的资源还在。" << endl;
    cout << "*sp = " << *sp << endl;
}
else
{
    cout << "提升失败。" << endl;
}

此时通过lock函数返回weak_ptr创建的shared_ptr对象,然后利用此对象来查看wp托管资源是否存在。

expired函数

等价于 use_count() == 0 。可能仍未对被管理对象调用析构函数,但此对象的析构已经临近(或可能已发生)。

weak_ptr<int> gw;

void f()
{
    if (!gw.expired()) 
    {
        cout << "gw is valid\n" << endl;
    }
    else 
    {
        cout << "gw is expired\n" << endl;
    }
}

int main()
{
    {
        auto sp = make_shared<int>(42);
        gw = sp;
        f();
    }
    f();
}
use_count

使用use_count == 0等价于expired。可能仍未对被管理对象调用析构函数,但此对象的析构已经临近(或可能已发生)。

删除器

对于如下代码:

string msg = "hello, world\n";
unique_ptr<FILE> up(fopen("wd.txt", "a+"));
fwrite(msg.c_str(),msg.size(), 1, up.get());

但是wd.txt文件中却是空的。原因是没有将文件进行关闭。

string msg = "hello, world\n";
unique_ptr<FILE> up(fopen("wd.txt", "a+"));
fwrite(msg.c_str(),msg.size(), 1, up.get());
fclose(up.get());

但是此时又出现free(): double free detected in tcache 2,即出现了double free错误。

原因在哪里呢?

查看源代码,可以看到unique_ptr的模板:

定义于头文件 <memory>
template< class T, class Deleter = std::default_delete<T> > class unique_ptr;(1)(C++11 起)
template < class T, class Deleter > class unique_ptr<T[], Deleter>;

其中的Deleter的默认值是std::default_delete<T>,这是一个struct,说明是一个类。其成员函数为

(构造函数)构造 default_delete 对象 (公开成员函数)
operator()删除对象或数组 (公开成员函数)

其中的operator()函数会在ptr上调用delete。使用以下方式进行删除:

delete ptr;
delete [] ptr;//ptr指向数组时

因此在代码中进行了fclose之后又进行了一次delete,出现double free错误。因此我们需要重新定义删除器,避免出现类似的错误。

对于unique_ptr而言

定义一个FILECloser类,代替默认的删除器。

struct FILECloser
{
    void operator()(FILE *ptr) const{
        if(ptr){
            fclose(ptr);
            cout << "fclose(ptr)" << endl;
        }
    }
};

int main()
{
    string msg("hello,world\n");
    unique_ptr<FILE,FILECloser> up(fopen("wd.txt", "a+"));
    fwrite(msg.c_str(),msg.size(), 1, up.get());
}

此时将delete用fclose进行了代替。

对于shared_ptr而言

对于下面的代码:

string msg = "hello, world\n";
shared_ptr<FILE> up(fopen("wd.txt", "a+"));
fwrite(msg.c_str(),msg.size(), 1, up.get());
string msg = "hello, world\n";
shared_ptr<FILE> up(fopen("wd.txt", "a+"));
fwrite(msg.c_str(),msg.size(), 1, up.get());
fclose(up.get());

发生的情况和unique_ptr一样。上面代码未调用fclose,下面代码发生double free错误。

不过shared_ptr和unique_ptr不同的是,shared_ptr的删除器在构造函数中进行定义。

template< class Y, class Deleter >
 shared_ptr( Y* ptr, Deleter d );

因此对于shared_ptr应该做如下处理来重定义删除器。

struct FILECloser
{
    void operator()(FILE *ptr) const{
        if(ptr){
            fclose(ptr);
            cout << "fclose(ptr)" << endl;
        }
    }
};

int main()
{
    string msg("hello,world\n");
    shared_ptr<FILE> up(fopen("wd.txt", "a+"), FILECloser());//注意这里构造函数传入的是FILECloser的对象
    fwrite(msg.c_str(),msg.size(), 1, up.get());
}

智能指针的误用

智能指针的误用都是一个问题:裸指针被不同的智能指针托管。

裸指针被不同智能指针托管导致double free错误

对于下面的代码有

Point *pt = new Point(1,2);
unique_ptr<Point> up(pt);
unique_ptr<Point> up2(pt);

此时会出现double free错误。发生的问题是执行了两次析构函数。

Point *pt = new Point(1,2);
Point *pt2 = new Point(3,4);

unique_ptr<Point> up(pt);
unique_ptr<Point> up2(pt2);

up.reset(up2.get());

此时也会发生double free错误。此时使用reset()赋值另一个指针给up智能指针,先使用关联的删除器释放pt,随后两个智能指针均指向pt2,导致销毁管理的unique_ptr对象时均会使用关联的删除器以删除对象,导致double free错误。

值得注意的是,上面的两种情况shared_ptr也是会出现一样的错误。

shared_ptr共享所有权
Point *pt = new Point(1,2);
shared_ptr<Point> sp(pt);
shared_ptr<Point> sp2(pt);//error 会出现double free错误

shared_ptr<Point> sp2 = sp;//ok

shared_ptr应该使用智能指针进行复制或赋值(不能用裸指针)来增加引用计数。

此外,对于下面的代码:

class Point
{
    //...
    Point *addPoint(Point *pt)
    {
        _ix += pt->_ix;
        _iy += pt->_iy;
        
        return this;
    }
};

int main(){
    Point *pt = new Point(1,2);
    Point *pt2 = new Point(3,4);
    shared_ptr<Point> sp(pt);
    shared_ptr<Point> sp2(pt2);

    shared_ptr<Point> sp3(sp->addPoint(sp2.get()));//error double free
}

这里的问题在于sp3会指向和sp相同的空间,发生上面说的double free错误。

改进方法是应该用智能指针来进行赋值。

class Point
{
    //...
    shared_ptr<Point> *addPoint(Point *pt)
    {
        _ix += pt->_ix;
        _iy += pt->_iy;
        
        return shared_ptr<Point>(this);
    }
};

int main(){
    Point *pt = new Point(1,2);
    Point *pt2 = new Point(3,4);
    shared_ptr<Point> sp(pt);
    shared_ptr<Point> sp2(pt2);

    shared_ptr<Point> sp3(sp->addPoint(sp2.get()));//error double free
}

但是还是出现了问题!原因在于return shared_ptr<Point>(this)是会创建临时对象来进行托管,而临时对象是用裸指针进行赋值的,当临时对象销毁时,会使得引用计数减少,导致最终还是出现了double free问题。

此时需要使用memory中的辅助类:enable_shared_from_this,其中有一个公有的成员函数shared_from_this,用于返回与之前存在的 std::shared_ptr 共享 *this 所有权的 std::shared_ptr<T> 。

class Point
:public std::enable_shared_from_this<Point>
{
    //...
    shared_ptr<Point> *addPoint(Point *pt)
    {
        _ix += pt->_ix;
        _iy += pt->_iy;
        
        return shared_from_this();
    }
};

int main(){
    Point *pt = new Point(1,2);
    Point *pt2 = new Point(3,4);
    shared_ptr<Point> sp(pt);
    shared_ptr<Point> sp2(pt2);

    shared_ptr<Point> sp3(sp->addPoint(sp2.get()));//ok
}

此时巧妙继承enable_shared_from_this类,就可以调用成员函数shared_from_this(),会增加引用计数,就不会导致double free错误了。

综上,shared_ptr如果想要共享所有权,需要使用enable_shared_from_this类中的shared_from_this

函数模板、类模板与可变参数模板

模板

函数模板如下:

template <typename T1, typename T2>
T1 add(T1 x, T2 y){
    
}

其中的<typename T1, typename T2>称为模板参数列表,typename可以被class替换。

类模板如下:

template <typename T>
class Test
{
private:
    T *_data;
}

其中的<typename T>称为模板参数列表,typename可以被class替换。

typename和class是通用的,完全一致。

主要的目的是:

  1. 简化程序,少写代码,维持结构清晰,大大提高程序的效率;
  2. 解决强类型语言的严格性和灵活性之间的冲突。

函数模板

函数模板与普通函数之间的关系

  • 函数模板与普通函数之间可以重载
template <class T>
T add(T x, T y, T z){
    cout << "T add(T, T, T)" << endl;
    return x + y + z;
}

int add(int x, int y){
    cout << "int add(int, int)" << endl;
    return x + y;
}

int main() {
    int x = 1;
    int y = 2;
    int z = 3;

    cout << add(x, y) << endl;
    cout << add(x, y, z) << endl;
    return 0;
}

此时的输出为

int add(int, int)
3
T add(T, T, T)
6
  • 普通函数优先
template <class T>
T add(T x, T y) {
    cout << "T add(T, T)" << endl;
    return x + y;
}

int add(int x, int y) {
    cout << "int add(int, int)" << endl;
    return x - y;
}

int main() {
    int x = 1;
    int y = 2;

    cout << add(x, y) << endl;
    return 0;
}

此时的输出为

int add(int, int)
-1
  • 函数模板之间可以进行重载

实例化

分为隐式实例化和显式实例化。

cout << "add(ia, ib) = " << add(ia, ib) << endl;//隐式实例化
cout << "add(ia, ib) = " << add<int>(ia, ib) << endl;//显式实例化

参数问题

template <class T>
T add(T x, T y) {
    cout << "T add(T, T)" << endl;
    return x + y;
}

int main() {
    int x = 1;
    double y = 2;

    cout << add(x, y) << endl;
    return 0;
}

此时会出现错误。因为x为int类型,y是double类型。x会将T推导为int类型,y会将T推导为double类型,那么T就产生了冲突(二义性)。

模板的推导时机

发生在编译的时候。

头文件与实现文件★

对于模板而言,不能写为头文件与实现文件的形式,也就是不能将声明与实现分开,否则就会报错。

分开可以编译,但是在链接的时候是有问题的。

链接的时候会出现问题的情况有两种:inline函数与模板。

但是工程量巨大的时候,一定要分成头文件和实现文件,那如何解决?

可以在头文件末尾包中引出实现文件#include "add.cc",此时在测试文件中既看到了头文件中的声明,也看到了实现文件中的实现。

模板的特化

对于模板而言,有些类型推导出来之后,使用模板是编译不过的,这个时候就可以将参数以特殊形式展现出来,然后写出特殊的版本,将这个过程称为模板的特化。

template <class T>
T add(T x, T y) {
    cout << "T add(T, T)" << endl;
    return x + y;
}

template <>
const char* add(const char *pstr1, const char *pstr2){
    cout << "const char* add(const char*, const char*)" << endl;
    size_t len = strlen(pstr1) + strlen(pstr2) + 1;
    char *ptmp = new char(len);
    strcpy(ptmp, pstr1);
    strcat(ptmp, pstr2);
    
    return ptmp;
}

模板的特化可以分成:

  • 全特化,也就是将全部参数以特殊版本的形式展示出来;

    上面的代码是全特化。所有的参数都特殊表示。

  • 偏特化(也叫作部分特化),将模板的部分参数以特殊版本的形式展示出来。

    有些书籍说函数模板不支持偏特化,有些书籍认为函数模板是否支持偏特化。我们这里不在意这个,以全特化为重点。

函数模板的参数类型

  • 类型参数。typename T这种就是类型参数;
  • 非类型参数。必须是整型(char/bool/short/int/long/size_t),不能使用浮点型(float/double)。

函数的参数是可以设定默认值的,同时模板的参数也可以设定默认值。

template <typename T = int, short Kmin = 10>
T multiply(T x, T y){
	return x * y * Kmin;
}

void test(){
    int ia = 3, ib = 4;
    double da = 3.3, db = 4.4;
    cout << "multiply(ia, ib) = "<< multiply(ia, ib) << endl;
    cout << "multiply(ia, ib) = "<< multiply<int, 4>(ia, ib) << endl;
    cout << "multiply(da, db) = "<< multiply<double, 4>(da, db) << endl;
}

此时有

multiply(ia, ib) = 120
multiply(ia, ib) = 48
multiply(da, db) = 58.08

对于函数模板的默认参数,有template <typename T = int, short Kmin>这个形式,在C++11之后支持。

如果是C++98就会和函数的默认参数保持一致。认为默认参数需要从右向左进行写,不能右边的默认参数不存在而左边的默认参数存在。

为了和之前和函数的默认参数保持一致,我们还是按照从右往左写比较好。

成员函数模板

类的成员函数也可以设置为模板:

class Example
{
public:
    Example(int ix = 0,int iy = 0)
    : _ix(ix)
    , _iy(iy)
    {
    }
    
    //不传入模板参数时默认强制转换为int
    template <typename T = int>
    T func()
    {
        return (T)_ix;
    }
    
private:
	int _ix;
    int _iy;
};

类模板

类模板的应用

这里实现一个栈Stack。

头文件Stack.h为

//类模板
template <typename T, size_t kSize = 10>//类型参数T与非类型参数kSize
class Stack
{
public:
	Stack()
		: _top(-1)
		, _data(new T[kSize]())
	{
	}
	~Stack();
	bool empty() const;
	bool full() const;
	void push(const T& t);
	void pop();
	T top() const;
private:
	int _top;
	T* _data;
};

实现文件Stack.cpp为

#include "Stack.h"
#include <iostream>
#include <string>

using std::cout;
using std::endl;

//类模板在类外面定义成员函数时候需要注意,模板是有类型的,需要使用参数加类型
template <typename T, size_t kSize>
Stack<T, kSize>::~Stack()
{
	if (_data)
	{
		delete[] _data;
		_data = nullptr;
	}
}

template <typename T, size_t kSize>
bool Stack<T, kSize>::empty() const
{
	return -1 == _top;//_top = -1
}

template <typename T, size_t kSize>
bool Stack<T, kSize>::full() const
{
	return _top == kSize - 1;
}

template <typename T, size_t kSize>
void Stack<T, kSize>::push(const T& t)
{
	if (!full())
	{
		_data[++_top] = t;
		cout << t << " has been pushed in Stack" << endl;
	}
	else
	{
		cout << "The Stack is full, cannot push any data" << endl;
	}
}

template <typename T, size_t kSize>
void Stack<T, kSize>::pop()
{
	if (!empty())
	{
		cout << _data[_top] << " has been poped from Stack" << endl;
		--_top;
	}
	else
	{
		cout << "The Stack is empty" << endl;
	}
}

template <typename T, size_t kSize>
T Stack<T, kSize>::top() const
{
	return _data[_top];
}

测试文件testStack.cpp为

#include "Stack.cpp"
#include <string>

using std::string;

void test()
{
	Stack<string, 2> st;
	cout << "Stack is empty: " << st.empty() << endl;
	st.push("hello");
	cout << "Stack is empty: " << st.empty() << endl;
	st.push("world");
	cout << "Stack is full: " << st.full() << endl;

	while (!st.empty()) {
		st.pop();
	}
}

int main() {
	test();
	return 0;
}

成员模板不能用virtual关键字修饰

template <typename T>
class Test
{
public:
    template <typename K>
    virtual K add(K x, K y){//error template may not be 'virtual'
        return x + y;
    }
private:
    T _x;
}

为什么模板不能用virtual关键字修饰:

  • 模板发生的时机在编译的时候,而虚函数要体现多态,发生的时机在运行的时候;
  • 有虚函数就会有虚表,而所有的虚函数的入口地址都会在虚表中,但是现在由于是模板形式的,也就不能确定到底可以推导为多少个虚函数,也就不能确定虚表的大小。

模板的嵌套

使用与函数模板也差不多,只是要注意模板的嵌套(函数模板与类模板都可以嵌套,比如函数参数是模板,类模板里面还有类模板),直接使用例子看类模板。

#include <iostream>

using namespace std;

template<class T>
class Outside
{
public:
	template <class R>
	class Inside
	{
	public:
		Inside(R x)
		{
			r = x;
		}
		void disp() { cout << "Inside: " << r << endl; }
	private:
		R r;
	};
	Outside(T x) : t(x)
	{}
	void disp()
	{
		cout << "Outside:";
		t.disp();
	}
private:
	Inside<T> t;
};

void test()
{
	Outside<int>::Inside<double> obin(3.5);
	obin.disp();
	Outside<int> obout(2);
	obout.disp();
}

int main() {
	test();
	return 0;
}

此时有输出为

Inside: 3.5
Outside:Inside: 2

可变模板参数

是C++11新增的最强大的特性之一,它对参数进行了高度的泛化,它能表示0到任意个数、任意类型的参数。

参数包

template <typename ...Args>//Args是模板参数包
class A;

template <typename ...T>//T是模板参数包
void f(T ...args);//args是函数参数包

Args标识符的左侧使用了省略号,在C++11中Args被称为“模板参数包”,表示可以接受任意多个参数作为模板参数,编译器将多个模板参数打包成“单个”的模板参数包。

args被称为函数参数包,表示函数可以接受多个任意类型的参数。在C++11标准中,要求函数参数包必须唯一,且是函数的最后一个参数;模板参数包则没有此要求。

对于如下的参数包

template <typename  ...Args> 
void print(Args ...args) {
    cout << "sizeof...(Args) = " << sizeof...(Args) << endl;
    cout << "sizeof...(args) = " << sizeof...(args) << endl;
}

void test() {
    print();
    print(1, "hello");
    print("hello", 3.3, true);
}

int main() {
    test();
    return 0;
}

运行后有

sizeof...(Args) = 0
sizeof...(args) = 0
sizeof...(Args) = 2
sizeof...(args) = 2
sizeof...(Args) = 3
sizeof...(args) = 3

print(“hello”, 3.3, true);为例,此时会建立一个模板template <typename T1,typename T2,typename T3>,T1推导为string,T2推导为double,T3推导为bool。在编译的过程中完成。

解包与打包

当使用参数包时,省略号位于参数名称的左侧,表示可以接受任意多个参数作为模板,这个过程也被称为打包

当使用参数包时,省略号位于参数名称的右侧,表示立即展开该参数,这个过程也被称为解包

递归实现输出参数包中参数★

那么我们应该如何将模板中接受的参数进行输出?

template <typename  ...Args> 
void print(Args ...args) 
{
    cout << ...args << endl;//error
    cout << args... << endl;//error
    cout << ...args... << endl;//error
}

上面的方法都是错误的。此时应该使用递归

void print() //此处进行重载,保证递归有退出条件
{
    cout << endl;
}

template <typename T1, typename  ...Args> 
void print(T1 t1, Args ...args) 
{
    cout << t1 << " ";
    print(args...);//递归
}

void test() {
    print();
    print(1, "hello");
    print("hello", 3.3, true);
}

int main() {
    test();
    return 0;
}

此时的输出为


1 hello
hello 3.3 1

应用

如果希望计算1 + 2 + 3 + … + 5,那么原来的方法是

int main(){
    int sum = 0;
    for(int i = 1; i <= 5; ++i){
        sum += i;
    }
    cout << sum << endl;
    return 0;
}

现在可以用参数包

int sum()
{
    return 0;
}

template <typename T, typename ...Args>
int sum(T x, Args ...args)
{
    return x + sum(args...);
}

int main(){
   	cout << "sum(1, 2, 3, 4, 5) = "<< sum(1, 2, 3, 4, 5) << endl;
    return 0;
}

STL六大组件、序列化容器

标准模板库STL

标准模板库STL,包含六大组件:

  1. 容器(container):用来存数据,数据结构。
    • 序列式容器: vector、list、deque
    • 关联式容器:set、map
    • 无序关联式容器:unordered_set、unordered_map
  2. 迭代器(iterator):类似于指针,可以将其称为泛型指针。可以不知道对象内部的情况下按照一定顺序访问聚合对象中的各个元素。
  3. 适配器(adapter):可以完成适配操作,让两个不完全配置的东西进行匹配。可以理解为模板。
    • 容器适配器:stack、queue
    • 迭代器适配器:back_insert_iterator、front_insert_iterator、insert_iterator
    • 函数适配器:bind、bind1st、bind2nd
  4. 算法(algorithm):可以操作容器里面的元素。泛型算法,不是针对具体的容器,而是单纯依赖迭代器和迭代器操作实现。
  5. 函数对象(仿函数,functor):做定制化操作。例如:unique_ptr模板中的删除器。
  6. 空间配置器(allocator):可以进行空间的申请与释放。

Q:如何学习上面这么多的内容?

A:学习对数据结构的增删查改。这是最为核心的内容。

    • 构造&初始化
    • 插入:头、尾、中间
    • 删除一个元素
    • 全部清空
    • 访问某个元素
    • 遍历
    • 依照元素信息返回来查找位置
    • 查找算法

序列式容器

查看源码,发现序列化容器vector、list和deque的模板都很相似。

定义于头文件 <list>
template< class T, class Allocator = std::allocator<T> > class list;
定义于头文件 <vector>
template< class T, class Allocator = std::allocator<T> > class vector;
定义于头文件 <deque>
template< class T, class Allocator = std::allocator<T> > class deque;

此外,他们的构造函数、遍历、插入等都非常相似。因此接下来以vector为例,进行序列化容器的初始化、遍历、插入等操作。

初始化

查看源码

查看vector的构造函数的源码:


< cpp‎ | container‎ | vector

(1)
vector();(C++17 前)
vector() noexcept(noexcept(Allocator()));(C++17 起) (C++20 前)
constexpr vector() noexcept(noexcept(Allocator()));(C++20 起)
(2)
explicit vector( const Allocator& alloc );(C++17 前)
explicit vector( const Allocator& alloc ) noexcept;(C++17 起) (C++20 前)
constexpr explicit vector( const Allocator& alloc ) noexcept;(C++20 起)
(3)
explicit vector( size_type count, const T& value = T(), const Allocator& alloc = Allocator());(C++11 前)
vector( size_type count, const T& value, const Allocator& alloc = Allocator());(C++11 起) (C++20 前)
constexpr vector( size_type count, const T& value, const Allocator& alloc = Allocator());(C++20 起)
(4)
explicit vector( size_type count );(C++11 起) (C++14 前)
explicit vector( size_type count, const Allocator& alloc = Allocator() );(C++14 起) (C++20 前)
constexpr explicit vector( size_type count, const Allocator& alloc = Allocator() );(C++20 起)
(5)
template< class InputIt > vector( InputIt first, InputIt last, const Allocator& alloc = Allocator() );(C++20 前)
template< class InputIt > constexpr vector( InputIt first, InputIt last, const Allocator& alloc = Allocator() );(C++20 起)
(6)
vector( const vector& other );(C++20 前)
constexpr vector( const vector& other );(C++20 起)
(7)
vector( const vector& other, const Allocator& alloc );(C++11 起) (C++20 前)
constexpr vector( const vector& other, const Allocator& alloc );(C++20 起)
(8)
vector( vector&& other );(C++11 起) (C++17 前)
vector( vector&& other ) noexcept;(C++17 起) (C++20 前)
constexpr vector( vector&& other ) noexcept;(C++20 起)
(9)
vector( vector&& other, const Allocator& alloc );(C++11 起) (C++20 前)
constexpr vector( vector&& other, const Allocator& alloc );(C++20 起)
(10)
vector( std::initializer_list<T> init, const Allocator& alloc = Allocator() );(C++11 起) (C++20 前)
constexpr vector( std::initializer_list<T> init, const Allocator& alloc = Allocator() );(C++20 起)

从各种数据源构造新容器,可选地使用用户提供的分配器 alloc

  1. 默认构造函数。构造拥有默认构造的分配器的空容器。

  2. 构造拥有给定分配器 alloc 的空容器。

  3. 构造拥有 count 个有值 value 的元素的容器。

  4. 构造拥有个 count 默认插入T 实例的容器。不进行复制。

  5. 构造拥有范围 [first, last) 内容的容器。

InputIt 是整数类型,则此构造函数拥有的效果同 vector(static_cast<size_type>(first), static_cast<value_type>(last), a) 。(C++11 前)
此重载仅若InputIt 满足老式输入迭代器 (LegacyInputIterator) 才参与重载决议,以避免和重载 (2) 的歧义。(C++11 起)
  1. 复制构造函数。构造拥有 other 内容的容器。
如同通过调用 std::allocator_traits<allocator_type>::select_on_container_copy_construction( other.get_allocator()) 获得分配器。(C++11 起)
  1. 构造拥有 other 内容的容器,以 alloc 为分配器。
在用于类模板实参推导时,仅从首个实参推导模板形参 Allocator(C++23 起)
  1. 移动构造函数。用移动语义构造拥有 other 内容的容器。分配器通过属于 other 的分配器移动构造获得。移动后,保证 otherempty()

  2. 有分配器扩展的移动构造函数。以 alloc 为新容器的分配器,从 other 移动内容;若 alloc != other.get_allocator() ,则它导致逐元素移动。(该情况下,移动后不保证 other 为空)

在用于类模板实参推导时,仅从首个实参推导模板形参 Allocator(C++23 起)
  1. 构造拥有 initializer_list init 内容的容器。
使用

此处因为还没有学习空间配置器,因此先忽视空间配置器没有默认参数的情况。

直接初始化为空
  1. 默认构造函数。构造拥有默认构造的分配器的空容器。
//vector();
vector<int> numbers;
初始化为多个数据(默认为0)
  1. 构造拥有 count 个有值 value 的元素的容器。
//vector( size_type count, const T& value, const Allocator& alloc = Allocator());
vector<int> numbers(10, 1);

不建议这么做。因为C++标准要求vector应该能在运行时高效快速地添加元素,这样在定义vector对象的时候设定大小就没有必要了。

使用迭代器的范围
  1. 构造拥有范围 [first, last) 内容的容器。
//template< class InputIt >  vector( InputIt first, InputIt last,     const Allocator& alloc = Allocator() );
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }
vector<int> number(arr, arr + 9);

此时vector是左闭右开的,也就是说10不会在容器中。

使用大括号
vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
拷贝/移动构造函数
//vector( const vector& other );
//vector( vector&& other );
vector<int> vec(100);
vector<int> vec2(vec);
vector<int> vec3(std::move(vec));

遍历

使用下标进行遍历(list不支持)
for(int idx = 0; idx != number.size(); ++idx){
    cout << numbers[idx] << " ";
}
使用迭代器进行遍历
  • 使用未经初始化的迭代器
vector<int>::iterator it;

for(it = number.begin(); it != number.end(); ++it){
    cout << *it << " ";
}
  • 使用初始化的迭代器
vector<int>::iterator it = number.begin();

for(; it != number.end(); ++it){
    cout << *it << " ";
}
  • 使用auto+迭代器
auto it = numbers.begin();
for(; it != numbers.end(); ++it)
{
	cout << *it << " ";
}
  • 使用for+auto

    此时没有使用拷贝操作,而是直接操作本身。

for(auto &elem : numbers)
{
	cout << elem << " ";
}

遍历的时候如果既有vector又有deque,还有list,还可以用模板来简化。

template <typename Container>
void display(const Container &con){
    for(auto &elem : con)
    {
        cout << elem << " ";
    }
}

int main()
{
    vector<int> vec = {1,2,3,4};
    list<int> lst = {1,2,3,4};
    display(vec);
    display(lst);
    return 0;
}

插入/删除

尾部插入/删除

对于三种序列式容器,在尾部插入和删除的方式都是一致的:push_backpop_back

此外,还有emplace_back就地生成一个对象来插入,减少一次拷贝。

头部插入/删除(vector不支持)

对于deque和list,支持在头部进行插入和删除:push_frontpop_front

Q: 为什么vector不支持头部插入和删除?

A: vector是一端开口的,只能在尾部进行操作。因为如果对头部进行插入/删除,会将后面的元素全部进行移动,这样会导致开销比较大,时间复杂度为O(n)。

image-20240304151925693

insert★(可能会迭代器失效)

查看源码,主要有以下四种常用的插入操作:

< cpp‎ | container‎ | vector

(省略了一些相似的插入操作方法)
iterator insert( iterator pos, const T& value );1)
iterator insert( const_iterator pos, T&& value );2)
void insert( iterator pos, size_type count, const T& value );3)
template< class InputIt > void insert( iterator pos, InputIt first, InputIt last);4)
iterator insert( const_iterator pos, std::initializer_list<T> ilist );5)

1-2) 在 pos 前插入 value 。 3) 在 pos 前插入 valuecount 个副本。 4) 在 pos 前插入来自范围 [first, last) 的元素。 5) 在 pos 前插入来自 initializer_list ilist 的元素。(大括号)

验证上面四种方式都可用,有代码如下:

void Traverse(vector<int> vec) {
	for (auto& elem : vec) {
		cout << elem << " ";
	}
	cout << endl;
}


int main() {
	vector<int> number = { 1,2,3,4,5 };
    Traverse(number);
	
	number.insert(number.begin() + 2, 6);//1-2)
	Traverse(number);
	
	number.insert(number.begin() + 2, 2, 7);//3)
	Traverse(number);

	vector<int> vec = { 8,8,8 };
	number.insert(number.begin() + 2, vec.begin(),vec.end()--);//4)
	Traverse(number);

	number.insert(number.begin() + 2, {9,9,9});//5)
	Traverse(number);
    
	return 0;
}

有输出如下:

1 2 3 4 5
1 2 6 3 4 5
1 2 7 7 6 3 4 5
1 2 8 8 8 7 7 6 3 4 5
1 2 9 9 9 8 8 8 7 7 6 3 4 5

需要注意的是,vector和deque插入之后,可能会发生迭代器失效,发生的原因是底层已经发生了扩容,而迭代器指向的还是老的空间,但是想在新的空间进行操作。迭代器不再会指向正确的位置,需要对迭代器进行重新进行赋值

erase
std::deque<T,Allocator>::erase
iterator erase( iterator pos );(1)(C++11 前)
iterator erase( const_iterator pos );(1)(C++11 起)
iterator erase( iterator first, iterator last );(2)(C++11 前)
iterator erase( const_iterator first, const_iterator last );(2)(C++11 起)

从容器擦除指定的元素。

  1. 移除位于 pos 的元素。
  2. 移除范围 [first; last) 中的元素。

也需要注意删除了之后迭代器的指向的位置的问题。

vector<int> vec = { 1,3,4,6,4,4,4,4,9,7 };

for (auto it = vec.begin();it != vec.end();++it) {
    if (*it == 4) {
        vec.erase(it);
        it = vec.begin();//很重要,否则删除了之后it会变成野指针
    }
}

或者有如下代码,效率比上面的高

vector<int> vec = { 1,3,4,6,4,4,4,4,9,7 };

for (auto it = vec.begin();it != vec.end();) {
    if (*it == 4) {
        it = vec.erase(it);//it被赋予了被删除元素的下一个元素的位置
    }
    else {
        ++it;
    }
}
clear

从容器擦除所有元素。此调用后 size() 返回零。

vector::capacity()返回原来的空间大小,说明空间没有被回收。

容量

shrink_to_fit(list不支持)

vector和deque如果希望能够回收多余的空间,可以使用shrink_to_fit函数。

swap
void swap( vector& other );

将内容与 other 的交换。不在单独的元素上调用任何移动、复制或交换操作。

resize

特点如下:

#include <iostream>
#include <vector>
int main()
{
    std::vector<int> c = {1, 2, 3};
    std::cout << "The vector holds: ";
    for(auto& el: c) std::cout << el << ' ';
    std::cout << '\n';
    c.resize(5);
    std::cout << "After resize up to 5: ";
    for(auto& el: c) std::cout << el << ' ';
    std::cout << '\n';
    c.resize(2);
    std::cout << "After resize down to 2: ";
    for(auto& el: c) std::cout << el << ' ';
    std::cout << '\n';
}

输出:

The vector holds: 1 2 3
After resize up to 5: 1 2 3 0 0
After resize down to 2: 1 2
reserve
void reserve( size_type new_cap );

增加 vector 的容量到大于或等于 new_cap 的值。若 new_cap 大于当前的 capacity() ,则分配新存储,否则该方法不做任何事。

好处是在new_cap 大于当前的 capacity() 之前迭代器都不会出现失效的问题。

vector容器

继承图

img

这里的_M_start表示开始位置,_M_finish表示vector最后一个元素的下一个位置,_M_end_of_storage表示vector所占用空间的末尾位置。

image-20240305164945705

特点

连续存储元素,这意味着不仅可通过迭代器,还能用指向元素的常规指针访问元素。这意味着指向 vector 元素的指针能传递给任何期待指向数组元素的指针的函数。

vector 的存储是自动管理的,按需扩张收缩。 vector 通常占用多于静态数组的空间,因为要分配更多内存以管理将来的增长。 vector 所用的方式不在每次插入元素时,而只在额外内存耗尽时重分配。分配的内存总量可用 capacity() 函数查询。

vector 上的常见操作复杂度(效率)如下:

  • 随机访问——常数 𝓞(1)
  • 在末尾插入或移除元素——均摊常数 𝓞(1)
  • 插入或移除元素——与到 vector 结尾的距离成线性 𝓞(n)

源码

template <class _Tp, class _Alloc = __STL_DEFAULT_ALLOCATOR(_Tp) >
class vector 
{
public:
    //将类型都做了typedef,也就是取了别名
    typedef _Tp value_type;
    typedef value_type* pointer;
    typedef const value_type* const_pointer;
    typedef value_type* iterator;
    typedef const value_type* const_iterator;
    typedef value_type& reference;
    typedef const value_type& const_reference;
    typedef size_t size_type;
    typedef ptrdiff_t difference_type;
    
    typedef _Vector_base<_Tp, _Alloc> _Base;
    typedef typename _Base::allocator_type allocator_type;

};

此处将很多类型做了typedef,也就是取了别名。这样对于vector、deque、list都可以用同一个名字来调用不同的底层原理实现相似的效果,这体现了泛型编程的思想。

下面的typedef typename _Base::allocator_type allocator_type;中的typename是为了严格表明后面表示的元素是类。

此外,有一些小细节可以在源码中看到

  reference at(size_type __n)
    { _M_range_check(__n); return (*this)[__n]; }
  const_reference at(size_type __n) const
    { _M_range_check(__n); return (*this)[__n]; }
  reference operator[](size_type __n) { return *(begin() + __n); }
  const_reference operator[](size_type __n) const { return *(begin() + __n); }

可以看到at()函数会检查是否越界,而operator[]函数则不会检查是否越界。

insert扩容原理(了解)

对于vector而言,使用push_back的时候,每次插入元素的个数是固定的1,但是对于insert而言,每次插入元素的个数是不确定的,所以insert插入的时候的扩容不能按照2倍。

那按照什么规律呢?

假设对于一个vector的size() = m = 11capacity = n = 18,待插入元素的个数为t

  • t < n - m,底层不会发生扩容;
  • n - m < t < m ,按照2*size()进行扩容;
  • n - m < t < n, t > m,按照t + m进行扩容;
  • n - m < t, t > n,按照t + m进行扩容。

deque容器

继承图

image-20240305165401106

如何理解迭代器_M_start和_M_finish?

他们都具有四个指针

  • _M_cur指向第一个元素的位置/最后元素的下一个元素的位置;
  • _M_first指向数组的第一个元素的位置;
  • _M_last指向数组的最后一个元素的下一个元素的位置;
  • _M_node指向数组中控器中负责控制该数组的位置。

image-20240305172402857

image-20240305172627471

特点

std::deque ( double-ended queue ,双端队列)是有下标顺序容器,它允许在其首尾两端快速插入及删除。另外,在 deque 任一端插入或删除不会非法化指向其余元素的指针或引用。

image-20240304151925693

std::vector 相反, deque 的元素不是相接存储的:典型实现用单独分配的固定大小数组的序列,外加额外的登记,这表示下标访问必须进行二次指针解引用,与之相比 vector 的下标访问只进行一次。

image-20240305171458914

deque 的存储按需自动扩展及收缩。扩张 deque 比扩张 std::vector 更优,因为它不涉及到复制既存元素到新内存位置。另一方面, deque 典型地拥有较大的最小内存开销;只保有一个元素的 deque 必须分配其整个内部数组(例如 64 位 libstdc++ 上为对象大小 8 倍; 64 位 libc++ 上为对象大小 16 倍或 4096 字节的较大者)。

deque 上常见操作的复杂度(效率)如下:

  • 随机访问——常数 O(1)

  • 在结尾或起始插入或移除元素——常数 O(1)

  • 插入或移除元素——线性 O(n),与 pos 和容器两端距离的较小者成线性。

    插入/移除元素的时候,可能是将pos前的元素向前,也可能是将pos后的元素向后,主要看pos与两端距离。

list容器

特点

std::list 是支持常数时间从容器任何位置插入和移除元素的容器。不支持快速随机访问。它通常实现为双向链表

image-20240304151925693

std::forward_list 相比,此容器提供双向迭代但在空间上效率稍低。

在 list 内或在数个 list 间添加、移除和移动元素不会非法化迭代器或引用。迭代器仅在对应元素被删除时非法化。

特殊操作

参考C++API文档。建议多看文档。

操作
merge合并二个已排序列表 (公开成员函数)
splice从另一个list中移动元素 (公开成员函数)
remove/remove_if移除满足特定标准的元素 (公开成员函数)
reverse将该链表的所有元素的顺序反转 (公开成员函数)
unique删除连续的重复元素 (公开成员函数)
sort对元素进行排序 (公开成员函数)
merge
void merge( list& other );

归并二个已排序链表为一个。链表应以升序排序。

先用sort()进行升序排序再进行merge。

此操作是稳定的:对于二个链表中的等价元素,来自 *this 的元素始终前驱来自 other 的元素,而且 *thisother 的等价元素顺序不更改。

splice
std::list<T,Allocator>::splice
void splice( const_iterator pos, list& other );(1)
void splice( const_iterator pos, list& other, const_iterator it );(2)
void splice( const_iterator pos, list& other, const_iterator first, const_iterator last);(3)

从一个 list 转移元素给另一个(可以是同一个list中)。不复制或移动元素,仅重指向链表结点的内部指针。没有迭代器或引用被非法化,指向被移动元素的迭代器保持合法,但现在指代到 *this 中,而非到 other 中。

  1. other 转移所有元素到 *this 中。元素被插入到 pos 所指向的元素之前。操作后容器 other 变为空。若 other 与 *this 指代同一对象则行为未定义。

  2. other 转移 it 所指向的元素到 *this 。元素被插入到 pos 所指向的元素之前。

  3. other 转移范围 [first, last) 中的元素到 *this 。元素被插入到 pos 所指向的元素之前。若 pos 是范围 [first,last) 中的迭代器则行为未定义。

需要注意的是都是插入到pos所指向的元素之前。

由于splice可以对同一个链表进行操作,因此可以用于实现链表的LRU操作。

remove/remove_if

移除所有满足特定标准的元素。参见C++API文档。

reverse

逆转容器中的元素顺序。不非法化任何引用或迭代器。

unique

从容器移除所有相继的重复元素。只留下相等元素组中的第一个元素。

由于只移除相继的重复元素,因此建议进行排序后再使用此函数

sort
std::list<T,Allocator>::sort
void sort();(1)
template< class Compare > void sort( Compare comp );(2)

以升序排序元素。保持相等元素的顺序。第一版本用 operator< 比较元素,第二版本用给定的比较函数 comp

若抛出异常,则 *this 中元素顺序未指定。

其中的比较函数comp一般用std::less<Key>()或者std::greater<Key>()

std::ostream& operator<<(std::ostream& ostr, const std::list<int>& list)
{
    for (auto &i : list) {
        ostr << " " << i;
    }
    return ostr;
}
 
int main()
{
    std::list<int> list = { 8,7,5,9,0,1,3,2,6,4 };
 
    std::cout << "before:     " << list << "\n";
    list.sort();// 相当于list.sort(std::less<int>());
    std::cout << "ascending:  " << list << "\n";
    list.sort(std::greater<int>());
    std::cout << "descending: " << list << "\n";
}

输出:

before:      8 7 5 9 0 1 3 2 6 4
ascending:   0 1 2 3 4 5 6 7 8 9
descending:  9 8 7 6 5 4 3 2 1 0

文本查询作业(未完成)

class TextQuery
{
public:
	//typedef vector<string>::size_type line_no;//C++98
    using line_no = vector<string>::size_type;//C++11

	TextQuery(std::ifstream &ifs);

    QueryResult query(const std::string&) const; 

    void display_map();     
private:
    shared_ptr<vector<string> > file; //将文件中的每一行的内容存起来
    map<string, shared_ptr<set<line_no>>> wm;//存放是单词,以及单词对应的行号 
   
    static string cleanup_str(const std::string&);
};

//将这个函数的行为成为预处理:读文件,将每一行的内容存在file中,将单词与行号存在wm中
TextQuery::TextQuery(ifstream &is)
: file(new vector<string>)
{
    string text;
    while (getline(is, text))
	{      
		file->push_back(text);  //将每一行的内容放在vector存起来,存每一行内容
		int n = file->size() - 1; //获取行号,并放在n中存起来
		istringstream line(text);//初始化istringstream对象
		string word;               
		while (line >> word) {        // 对于串Io而言,默认以空格为分隔符
            word = cleanup_str(word);//处理每次获取的原始单词word,然后得到新的单词word
           
            lineType &lines = wm[word]; //使用map的下标进行操作(可以查找、插入) 
            if (!lines) // 判断智能指针是不是为空
                lines.reset(new set<line_no>); // 为空就将其初始化
            lines->insert(n); //将行号插入到set中
		}
	}
}

关联式容器与非关联式容器

参考set和map的使用

关联式容器

关联式容器的共同特点;

  1. 元素都是有序的;
  2. 底层都是使用红黑树数据结构;
  3. 查找的时间复杂度为O(logN)。

set/multiset

特征

定义于头文件 <set>
template< class Key, class Compare = std::less<Key>, class Allocator = std::allocator<Key> > class set;(1)
namespace pmr { template <class Key, class Compare = std::less<Key» using set = std::set<Key, Compare, std::pmr::polymorphic_allocator<Key»; }(2)

std::set 是关联容器,含有 Key 类型对象的已排序集。用比较函数 比较 (Compare) 进行排序。搜索、移除和插入拥有对数复杂度。 set 通常以红黑树实现。

std::multiset 是含有 Key 类型对象有序集的容器。不同于 set ,它允许多个 Key 拥有等价的值。用关键比较函数 Compare 进行排序。搜索、插入和移除操作拥有对数复杂度。

image-20240304151925693

屏幕截图 2024-02-02 204129

此外,begin指向第一个元素,end指向最后一个元素的下一个位置。

使用

插入
insert/insert_or_assign

insert - 若容器尚未含有带等价关键的元素,则插入元素到容器中。

insert_or_assign - 插入元素,或若键已存在则赋值给当前元素。(multimap不支持)

具体使用查看C++API文档。

C ++ set insert()用于在集合中插入新元素

因为元素键在集合中是唯一的,所以插入操作首先检查给定键是否已存在于集合中,如果键存在于集合中,则不会将其插入到集合中并迭代到现有的迭代器返回键,否则将新元素插入集合。

//普通引用方式传参
pair<iterator,bool> insert (const value_type& val);

此处的返回值pair是一个结构体,定义在头文件utility中。返回值中返回布尔对用来表示返回指向新插入元素的迭代器,以及是否发生插入。

可以用first和second分别查看成员对象,即新插入元素的迭代器和表示是否发生插入的bool值。

例:

int main()
{
    set<int> number = {1, 3, 5, 7, 3, 9};
    
    pair<set<int>::iterator, bool> ret = number.insert(8);
    if(ret.second)//使用返回值pair中的second来判断是否成功
    {
        cout << "success" << *ret.first << endl;
    }
    else
    {
        cout << "fail" << endl;
    }
}
emplace/emplace_hint/try_emplace

emplace - 原位构造元素。若容器中无拥有该关键的元素,则插入以给定的 args 原位构造的新元素到容器。

emplace_hint - 使用提示原位构造元素。插入元素到尽可能靠近正好在 hint 之前的位置。原位构造元素,即不进行复制或移动操作。

try_emplace - 若键不存在则原位构造,若键存在则不做任何事。(multimap不支持)

具体使用查看C++API文档。

删除
erase

从容器移除指定的元素。

具体使用查看C++API文档。

clear
修改
不支持修改

set/multiset容器也不支持修改。

如果希望修改

如果希望修改,可以删除后再插入。

查找
不支持使用下标访问

set/multiset容器并不是随机访问迭代器,因此不支持下标访问。

不支持下标访问运算符operator[]

构建自定义类型的set/multiset时的排序问题★

template<class Key, 
		 class Compare = [std::less]<Key>, 
		 class Allocator = [std::allocator]<Key> >
class set;

std::set/multiset 是关联容器,含有 Key 类型对象的已排序集。可以查看std::less的实现如下:

template<class T>
struct less;

其中有成员函数

bool operator()(const T& lhs, const T& rhs) const
{
    return lhs < rhs;
}

进行比较的函数对象。调用类型T上的operator< 。但是如果是自定义类型,会发生一个问题:std::less<Type>中调用operator<运算符函数是不能够比较自定义类型的。此时需要进行改写,以Complex类为例。

模板的特化
namespace std
{
//此时对模板进行特化
template <>
struct less<Complex>
{
    bool operator()(const Complex& lhs, const Complex& rhs) const
    {
        if(lhs._real*lhs._real + lhs._imag*lhs._imag < rhs._real*rhs._real + rhs._imag*rhs._imag){
            return true;
        }
        else{
            return false;
        }
    }
};
}//end of namespace std
运算符重载

一般地,可以设置为友元函数来访问内部数据成员,或者可以在类内部设置一个函数来输出内部数据成员。

bool operator<(const Complex &lhs, const Complex &rhs){
    if(lhs._real*lhs._real + lhs._imag*lhs._imag < rhs._real*rhs._real + rhs._imag*rhs._imag){
        return true;
    }
    else{
        return false;
    }
}
函数对象

自己写一个类,重载函数调用运算符。

struct CompareComplex{
    bool operator()(const Point &lhs, const Point &rhs)
    {
        if(lhs._real*lhs._real + lhs._imag*lhs._imag < rhs._real*rhs._real + rhs._imag*rhs._imag)
        {
        return true;
    	}
    	else{
        	return false;
    	}
    }
};

此时将std::less用这个函数对象进行替代。

set<Complex, CompareComplex> number;

map/multimap

类似地,使用方法参考API文档。

接下来只关注map/multimap的重点。

  • 插入map的时候,需要注意operator[]和at的区别。
  • 插入的时候,map返回的是pair类型。pair.fisrt表示迭代器,指向插入的元素/阻止元素插入的迭代器;pair.second表示是否插入成功。multimap返回的是迭代器。
  • multimap不支持下标访问运算符。可以使用equal_range/lower_bound/upper_bound来查找key值。
  • map和multimap都支持find和count函数。
  • 默认情况下,会进行按照key值升序排列。

非关联式容器

哈希相关概念

哈希函数

通过key值找对对应的位置值

index = H(key)
哈希函数的构造方式

image-20240123153716711

哈希冲突
H(key1) = H(key2),注意:key1 != key2
解决哈希冲突的方法
  • 开放地址法
  • 链地址法(STL使用这种方法)
  • 再散列法
  • 建立公共溢出区

image-20240123154344982

非关联式容器的特点

  • 底层使用哈希表实现;
  • 元素是无序的;
  • 查找的时间复杂度是O(1)。

unordered_set/unordered_multiset

特征

template<
    class Key,
    class Hash = std::hash<Key>,
    class KeyEqual = std::equal_to<Key>,
    class Allocator = std::allocator<Key>
> class unordered_set;
//unordered_multiset相似

unordered_set 是含有 Key 类型唯一对象集合的关联容器。搜索、插入和移除拥有平均常数时间复杂度。

unordered_multiset 是关联容器,含有可能非唯一 Key 类型对象的集合。搜索、插入和移除拥有平均常数时间复杂度。

在内部,元素并不以任何特别顺序排序,而是组织进桶中。元素被放进哪个桶完全依赖其值的哈希。这允许对单独元素的快速访问,因为哈希一旦确定,就准确指代元素被放入的桶。

不可修改容器元素(即使通过非 const 迭代器),因为修改可能更改元素的哈希,并破坏容器。

操作

参考C++API,支持查找、插入、删除和清空。

构建自定义类型的unordered_set/unordered_multiset时的Hash策略问题

对于模板参数而言,有两种方法:模板特化或函数对象。

对于equal_to有三种方法:模板的特化、函数对象和运算符重载。

unordered_map/unordered_multimap

特征

unordered_map 是关联容器,含有带唯一键的键-值 pair 。搜索、插入和元素移除拥有平均常数时间复杂度。

unordered_multimap 是无序关联容器,支持等价的关键(一个 unordered_multimap 可含有每个关键值的多个副本)和将关键与另一类型的值关联。 unordered_multimap 类支持向前迭代器。搜索、插入和移除拥有平均常数时间复杂度。

元素在内部不以任何特定顺序排序,而是组织进桶中。元素放进哪个桶完全依赖于其键的哈希。这允许对单独元素的快速访问,因为一旦计算哈希,则它准确指代元素所放进的桶。

操作

支持查找、插入、删除、清空。

特别地,unordered_map支持下标访问。

构建自定义类型的unordered_map/unordered_multimap

特别地,string关于hash都已经进行了特化。因此在以后使用string的时候,虽然string是一个类类型,但是可以像是内置类型一样使用。因为其基本操作都已经进行了实现。

容器的使用总结

元素是不是有顺序的

如果元素是有序的,最应该想到关联式容器,其次再用序列式容器,最不应该想到的是无序关联式容器。

注意:序列式容器也可以进行排序,只是需要调用相应的函数。list有成员函数sort,vector与deque有算法库中的sort函数。

元素有没有下标

  • 序列式容器,vector与deque
  • 关联式容器,map
  • 无序关联式容器,unordered_map

查找的时间复杂度

  • 序列式容器,O(N)
  • 关联式容器,O(logN)
  • 无序关联式容器,O(1)

迭代器的类型

  • 随机访问迭代器:vector与deque
  • 双向迭代器:list,关联式容器
  • 前向迭代器:无序关联式容器

容器适配器与迭代器

适配器adapter

定义

适配器就是Interface(接口),对容器、迭代器和算法进行包装,但其实质还是容器、迭代器和算法,只是不依赖于具体的标准容器、迭代器和算法类型,容器适配器可以理解为容器的模板,迭代器适配器可理解为迭代器的模板,算法适配器可理解为算法的模板。

本质

适配器是使一事物的行为类似于另一事物的行为的一种机制。

分类

  • 容器适配器 stack、queue、priority_queue
  • 迭代器适配器 back_insert_iterator、front_insert_iterator、insert_iterator
  • 函数适配器 bind1st、bind2nd、bind not1、not2 否定器 mem_fn 成员函数绑定器,后面会讲解

priority_queue

模板参数

定义于头文件 <queue>
template< class T, class Container = std::vector<T>, class Compare = std::less<typename Container::value_type> > class priority_queue;

priority_queue 是容器适配器,它提供常数时间的(默认)最大元素查找,对数代价的插入与释出。

最常用的容器适配器有三个:stack、queue和priority_queue。

可用用户提供的 Compare 更改顺序,例如,用 std::greater<T> 将导致最小元素作为 top() 出现。

priority_queue 工作类似管理某些随机访问容器中的,优势是不可能突然把堆非法化。

基本特征

优先级队列是一个封装了 vector<T> 容器的适配器类模板。底层使用的是堆排序,默认使用的是大根堆。

原理是当有新的元素插入进来的时候会与对堆顶进行比较,如果堆顶比新插入的元素要小,就会满足std::less,将新插入的元素与上一次的堆顶进行置换,随后进行堆的调整。

复习数据结构中的堆排序。

针对自定义类型★

有三种方法:模板的特化、函数对象和运算符重载。

在竞赛中,我们常常对自定义类型使用运算符重载。特别地,还会用对内置类型取负值来实现小根堆的效果(此时不需要调用std::greater)。

迭代器Iterator

迭代器(iterator)模式又称为游标(Cursor)模式,用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示。

迭代器的本质与类型

迭代器是种更高层次的抽象,它使得算法独立于容器,这使得算法独立于类型。

image-20240124100736442

流迭代器

流迭代器是特殊的迭代器,可以将输入/输出流作为容器看待(因为输入输出都有缓冲区的概念)。

vector<int> vec{1, 4, 7, 9};
ostream_iterator<int> osi(cout, "\n");
copy(vec.begin(), vec.end(), osi);

first = vec.begin();
last = vec.end();
d_first = osi;

OutputIt copy(InputIt first, InputIt last, OutputIt d_first)
{
    while (first != last) 
    {
        #if 0
        t = first;
        p = d_first;
        first++;
        d_first++;
        *p = *t;
        #endif
            osi     =   4
        *d_first++ = *first++;
    }
    return d_first;
}
All rights reserved.
使用 Hugo 构建
主题 StackJimmy 设计