赞
踩
阿秀的学习笔记:https://interviewguide.cn/
main函数执行之前,主要就是初始化系统相关资源:
static
变量和global
全局变量,即.data
段的内容short
,int
,long
等为0
,bool
为FALSE
,指针为NULL
等等,即.bss
段的内容main
之前调用构造函数,这是可能会执行前的一些代码argc
,argv
等传递给main
函数,然后才真正运行main
函数__attribute__((constructor))
main函数执行之后:
atexit
注册一个函数,它会在main 之后执行;__attribute__((destructor))
update1:https://github.com/forthespada/InterviewGuide/issues/2 ,由
stanleyguo0207
提出 - 2021.03.22
c++11以后引入两个关键字 alignas (opens new window)与 alignof (opens new window)。其中alignof
可以计算出类型的对齐方式,alignas
可以指定结构体的对齐方式。
但是alignas
在某些情况下是不能使用的,具体见下面的例子:
// alignas 生效的情况 struct Info { uint8_t a; uint16_t b; uint8_t c; }; std::cout << sizeof(Info) << std::endl; // 6 2 + 2 + 2 std::cout << alignof(Info) << std::endl; // 2 struct alignas(4) Info2 { uint8_t a; uint16_t b; uint8_t c; }; std::cout << sizeof(Info2) << std::endl; // 8 4 + 4 std::cout << alignof(Info2) << std::endl; // 4
alignas
将内存对齐调整为4个字节。所以sizeof(Info2)
的值变为了8。
// alignas 失效的情况 struct Info { uint8_t a; uint32_t b; uint8_t c; }; std::cout << sizeof(Info) << std::endl; // 12 4 + 4 + 4 std::cout << alignof(Info) << std::endl; // 4 struct alignas(2) Info2 { uint8_t a; uint32_t b; uint8_t c; }; std::cout << sizeof(Info2) << std::endl; // 12 4 + 4 + 4 std::cout << alignof(Info2) << std::endl; // 4
若alignas
小于自然对齐的最小单位,则被忽略。
alignas
是无效的。应该使用#pragma pack(push,1)
或者使用__attribute__((packed))
。#if defined(__GNUC__) || defined(__GNUG__) #define ONEBYTE_ALIGN __attribute__((packed)) #elif defined(_MSC_VER) #define ONEBYTE_ALIGN #pragma pack(push,1) #endif struct Info { uint8_t a; uint32_t b; uint8_t c; } ONEBYTE_ALIGN; #if defined(__GNUC__) || defined(__GNUG__) #undef ONEBYTE_ALIGN #elif defined(_MSC_VER) #pragma pack(pop) #undef ONEBYTE_ALIGN #endif std::cout << sizeof(Info) << std::endl; // 6 1 + 4 + 1 std::cout << alignof(Info) << std::endl; // 6
#if defined(__GNUC__) || defined(__GNUG__) #define ONEBYTE_ALIGN __attribute__((packed)) #elif defined(_MSC_VER) #define ONEBYTE_ALIGN #pragma pack(push,1) #endif /** * 0 1 3 6 8 9 15 * +-+---+-----+---+-+-------------+ * | | | | | | | * |a| b | c | d |e| pad | * | | | | | | | * +-+---+-----+---+-+-------------+ */ struct Info { uint16_t a : 1; uint16_t b : 2; uint16_t c : 3; uint16_t d : 2; uint16_t e : 1; uint16_t pad : 7; } ONEBYTE_ALIGN; #if defined(__GNUC__) || defined(__GNUG__) #undef ONEBYTE_ALIGN #elif defined(_MSC_VER) #pragma pack(pop) #undef ONEBYTE_ALIGN #endif std::cout << sizeof(Info) << std::endl; // 2 std::cout << alignof(Info) << std::endl; // 1
这种处理方式是alignas
处理不了的。
update1:https://github.com/forthespada/InterviewGuide/issues/2 ,由
stanleyguo0207
提出 - 2021.03.22
void test(int *p) { int a=1; p=&a; cout<<p<<" "<<*p<<endl; } int main(void) { int *p=NULL; test(p); if(p==NULL) cout<<"指针p为NULL"<<endl; return 0; } //运行结果为: //0x22ff44 1 //指针p为NULL void testPTR(int* p) { int a = 12; p = &a; } void testREFF(int& p) { int a = 12; p = a; } void main() { int a = 10; int* b = &a; testPTR(b);//改变指针指向,但是没改变指针的所指的内容 cout << a << endl;// 10 cout << *b << endl;// 10 a = 10; testREFF(a); cout << a << endl;//12 }
在编译器看来, int a = 10; int &b = a;
等价于 int * const b = &a;
而 b = 20; 等价于 *b = 20; 自动转换为指针和自动解引用.
需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的
对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小
引用是C++的一种复合类型,是已定义的变量的别名,与原变量在同一个内存地址。引用常常作为函数的形参,此时函数将使用原始数据(而不是原始数据的副本)。
类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式
栈空间默认是4M, 堆区一般是 1G - 4G
堆 | 栈 | |
---|---|---|
管理方式 | 堆中资源由程序员控制(容易产生memory leak) | 栈资源由编译器自动管理,无需手工控制 |
内存管理机制 | 系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序 (大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放入空闲链表中) | 只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。 (这一块理解一下链表和队列的区别,不连续空间和连续空间的区别,应该就比较好理解这两种机制的区别了) |
空间大小 | 堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的), 堆大小受限于计算机系统中有效的虚拟内存(32bit 系统理论上是4G),所以堆的空间比较灵活,比较大 | 栈是一块连续的内存区域,大小是操作系统预定好的,windows下栈大小是2M(也有是1M,在 编译时确定,VC中可设置) |
碎片问题 | 对于堆,频繁的new/delete会造成大量碎片,使程序效率降低 | 对于栈,它是有点类似于数据结构上的一个先进后出的栈,进出一一对应,不会产生碎片。 (看到这里我突然明白了为什么面试官在问我堆和栈的区别之前先问了我栈和队列的区别) |
生长方向 | 堆向上,向高地址方向增长。 | 栈向下,向低地址方向增长。 |
分配方式 | 堆都是动态分配(没有静态分配的堆) | 栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由alloca函数分配, 但栈的动态分配的资源由编译器进行释放,无需程序员实现。 |
分配效率 | 堆由C/C++函数库提供,机制很复杂。所以堆的效率比栈低很多。 | 栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门寄存器存放栈地址,栈操作有专门指令。 |
形象的比喻
栈就像我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
毫无疑问是栈快一点。
因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。
而堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。
int *p[10]
int (*p)[10]
int *p(int)
int (*p)(int)
int *p[10]
表示指针数组,强调数组概念,是一个数组变量,数组大小为10,数组内每个元素都是指向int类型的指针变量。int (*p)[10]
表示数组指针,强调是指针,只有一个变量,是指针类型,不过指向的是一个int类型的数组,这个数组大小是10。int *p(int)
是函数声明,函数名是p,参数是int类型的,返回值是int *类型的。int (*p)(int)
是函数指针,强调是指针,该指针指向的函数具有int类型参数,并且返回值是int类型的。相同点
不同点
int *p = new float[2]; //编译错误
int *p = (int*)malloc(2 * sizeof(double));//编译无错误
不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。
typedef char * p_char
和#define p_char char *
区别巨大。
#define p_char char *
定义两个字符串(p_char a,b),第二个变量 b 的类型就是 char 类型,而不是 char * 了,因为宏定义只是将 p_char 替换成 char *int main(int argc, char const *argv[]){
const char* str = "name";
sizeof(str); // 取的是指针str的长度,是8(64位系统中指针长度是8;32为系统中指针长度是4)
strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
return 0;
}
在16题中有提到sizeof(str)的值为8,是在64位的编译环境下的,指针的占用大小为8字节;
而在32位环境下,指针占用大小为4字节。
一个指针占内存的大小跟编译环境有关,而与机器的位数无关。
还有疑问的,可以自行打开Visual Studio编译器自己实验一番。
update1:https://www.nowcoder.com/discuss/597948 ,网友“ 牛客191489444号 ”指出笔误,感谢!
update2:《C++ Primer 5th》 P56页有明确说明常量指针和指针常量,阿秀特意去确认了-20210521。
多说一句,网上关于指针常量和常量指针的说法很多跟书本上都不一致,甚至百度百科上跟《C++ Primer 5th》书上在指针常量和常量指针的说法刚好相反,鉴于百度百科是人人都可以去编辑,因此我信书。
也希望各位遇到问题时要多去查阅资料,多去确认,不要因为某些博客或者文章说了就确认无疑。
假设数组int a[10]; int (*p)[10] = &a;
其中:
包括但不限于:
引用型变量是其他变量的一个别名,我们可以认为他们只是名字不相同,其他都是相同的。
语言特性
垃圾回收
应用场景
相同点
不同点
引申:C++ 和 C 的 struct 区别
编译阶段
安全性
内存占用
不考虑类的情况
考虑类的情况
static 成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为 static ;可以被非static成员函数任意访问。
class Student
{
public:
int a = 10;
static int age;
};
int Student::age = 20;
static 成员函数:不具有 this 指针,无法访问类对象的非 static 成员变量和非 static 成员函数;**不能被声明为 const 、虚函数 和 volatile **;可以被非 static 成员函数任意访问
不考虑类的情况
const 常量在定义时必须初始化,之后无法更改
const 形参可以接收 const 和非 const 类型的实参,例如: i 可以是 int 型或者 const int 型
void fun(const int& i){
//...
}
考虑类的情况
const 成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其 const 数据成员的值可以不同,所以不能在类中声明时初始化
const 成员函数:const 对象不可以调用非 const 成员函数;非 const 对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值
#include<iostream> using namespace std; class Student { public: int a = 10; static int age; void hello() { cout << "hello" << endl; } void world() const { cout << "world" << endl; } }; int Student::age = 20; int main() { Student* s = new Student; cout << s->a << endl; cout << Student::age << endl; s->hello(); s->world(); const Student* s2 = new Student; cout << s2->a << endl; cout << Student::age << endl; //s2->hello(); s2->world(); }
补充一点 const 相关:const 修饰变量是也与 static 有一样的隐藏作用。只能在该文件中使用,其他文件不可以引用声明使用。 因此在头文件中声明 const 变量是没问题的,因为即使被多个文件包含,链接性都是内部的,不会出现符号冲突。
概念区分
举个例子
int a = 10;int* const b1 = &a; //顶层const,b1本身是一个常量
const int* b2 = &a; //底层const,b2本身可变,所指的对象是常量
const int b3 = 20; //顶层const,b3是常量不可变
const int* const b4 = &a; //前一个const为底层,后一个为顶层,b4不可变
const int& b5 = a; //用于声明引用变量,都是底层const
区分作用
const int a;int const a;const int *a;int *const a;
当在父类中使用了虚函数时候,你可能需要在某个子类中对这个虚函数进行重写,以下方法都可以:
class A
{
virtual void foo();
};
class B : public A
{
void foo(); //OK
virtual void foo(); // OK
void foo() override; //OK
};
如果不使用override,当你手一抖,将 foo() 写成了 f00() 会怎么样呢?结果是编译器并不会报错,因为它并不知道你的目的是重写虚函数,而是把它当成了新的函数。如果这个虚函数很重要的话,那就会对整个程序不利。所以,override的作用就出来了,它指定了子类的这个虚函数是重写的父类的,如果你名字不小心打错了的话,编译器是不会编译通过的:
class A
{
virtual void foo();
};
class B : public A
{
virtual void f00(); //OK,这个函数是B新增的,不是继承的
virtual void f0o() override; //Error, 加了override之后,这个函数一定是继承自A的,A找不到就报错
};
当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。例子如下:
class Base { virtual void foo(); }; class A : public Base { void foo() final override; // foo 被override并且是最后一个override,在其子类中不可以重写 }; class B final : A // 指明B是不可以被继承的 { void foo() override; // Error: 在A中已经被final了 }; class C : B // Error: B is final { };
当用于类类型对象时,初始化的拷贝形式和直接形式有所不同:
拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。举例如下
string str1("I am a string");//语句1 直接初始化
string str2(str1);//语句2 直接初始化,str1是已经存在的对象,直接调用拷贝构造函数对str2进行初始化
string str3 = "I am a string";//语句3 拷贝初始化,先为字符串”I am a string“创建临时对象,再把临时对象作为参数,使用拷贝构造函数构造str3
string str4 = str1;//语句4 拷贝初始化,这里相当于隐式调用拷贝构造函数,而不是调用赋值运算符函数
为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了
(语句1和语句3等价),但是需要辨别两种情况。
感谢网友勘误:”string str2(str1); // 使用的是拷贝构造函数初始化而且直接初始化“,已修正,2021.10.12 -https://github.com/forthespada/InterviewGuide/issues/14
class A{ public: int num1; int num2; public: A(int a=0, int b=0):num1(a),num2(b){}; A(const A& a){}; //重载 = 号操作符函数 A& operator=(const A& a){ num1 = a.num1 + 1; num2 = a.num2 + 1; return *this; }; }; int main(){ A a(1,1); A a1 = a; //拷贝初始化操作,调用拷贝构造函数 A b; b = a;//赋值操作,对象a中,num1 = 1,num2 = 1;对象b中,num1 = 2,num2 = 2 return 0; } /* 输出结果: a、b 构造函数 A 构造函数 a、b 构造函数 operator= 函数 */
为了能够正确的在 C++ 代码中调用 C 语言的代码:在程序中加上 extern “C” 后,相当于告诉编译器这部分代码是 C 语言写的,因此要按照 C 语言进行编译,而不是 C++ ;
哪些情况下使用 extern “C” :
(1)C++ 代码中调用 C 语言代码;
(2)在 C++ 中的头文件中使用;
(3)在多个人协同开发时,可能有人擅长 C 语言,而有人擅长 C++ ;
举个例子,C++ 中调用 C 代码:
#ifndef _MY_HANDLE_H_
#define _MY_HANDLE_H_
extern "C" {
typedef unsigned int result_t;
typedef void* my_handler_t;
my_handle_t create_handle(const char* name);
result_t operate_on_handle(my_handle_t handle);
void close_handle(my_handle_t handle);
}
综上,总结出使用方法**,在 C 语言的头文件中,对其外部函数只能指定为 extern 类型,C 语言中不支持 extern “C” 声明,在 .c 文件中包含了 extern “C” 时会出现编译语法错误。**所以使用 extern “C” 全部都放在于 cpp 程序相关文件或其头文件中。
总结出如下形式:
(1)C++调用C函数:
//xx.h
extern int add(...)
//xx.c
int add()
{
}
//xx.cpp
extern "C"{
#include "xx.h"
}
(2)C调用C++函数
//xx.h
extern "C"{
int add();
}
//xx.cpp
int add(){
}
//xx.c
extern int add();
都是是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。
int main(void) {
int* p; // 未初始化
std::cout << *p << std::endl; // Error,未初始化就用
return 0;
}
因此,为了防止出错,对于指针初始化时都是赋值为 nullptr
,这样在使用时编译器就会直接报错,产生非法内存访问。
int main(void) {
int* p = nullptr;
int* p2 = new int;
p = p2;
delete p2;
}
此时 p和p2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。需要设置为p=p2=nullptr
。此时再使用,编译器会直接保错。 避免野指针比较简单,但悬空指针比较麻烦。C++ 引入了智能指针,C++ 智能指针的本质就是避免悬空指针的产生。
产生原因及解决办法:
野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。
悬空指针:指针 free 或 delete 之后没有及时置空 => 释放操作后立即置空。
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。
类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。
C 只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式类型转换。然而,C 中相当多的操作是不安全的。以下是两个十分常见的例子:
#include<stdio.h>
int main()
{
printf("整型输出:%d\n",10);
printf("浮点型输出:%f\n",10);
return 0;
}
/*
[root@localhost linux]# ./a.out
整型输出:10
浮点型输出:0.000000
*/
上述代码中,使用 %d 控制整型数字的输出,没有问题,但是改成 %f 时,明显输出错误,再改成 %s 时,运行直接报 segmentation fault 错误
#include<stdio.h>
int main()
{
printf("整型输出:%d\n",10);
// %f 改成 %s 时,运行直接报 segmentation fault 错误
printf("浮点型输出:%s\n",10);
return 0;
}
/*
[root@localhost linux]# ./a.out
整型输出:10
段错误
*/
malloc 是 C 中进行内存分配的函数,它的返回类型是 void*
即空类型指针,常常有这样的用法char* pStr=(char*)malloc(100*sizeof(char))
,这里明显做了显式的类型转换。
类型匹配尚且没有问题,但是一旦出现int* pInt=(int*)malloc(100*sizeof(char))
就很可能带来一些问题,而这样的转换 C 并不会提示错误。
如果 C++ 使用得当,它将远比 C 更有类型安全性。相比于 C 语言,C++ 提供了一些新的机制保障类型安全:
void*
void*
为参数的函数可以改写为 C++ 模板函数,而模板是支持类型检查的;#define constants
,它是有类型、有作用域的,而#define constants
只是简单的文本替换例1:使用 void* 进行类型转换
#include<iostream>
using namespace std;
int main() {
int i = 5;
void* pInt = &i;
double d = (*(double *)pInt);
cout << "转换后输出:" << d << endl;
}
/*
[root@localhost linux]# ./a.out
转换后输出:-3.69447e+134
*/
例2:不同类型指针之间转换
#include<iostream> using namespace std; class Parent { }; class Child1 : public Parent { public: int i; Child1(int e) : i(e) { } }; class Child2 : public Parent { public: double d; Child2(double e):d(e) { } }; int main() { Child1 c1(5); Child2 c2(4.1); Parent* pp; Child1* pc1; pp = &c1; pc1 = (Child1*)pp; // 类型向下转换 强制转换,由于类型仍然为 Child1 不造成错误 cout << pc1->i << endl; // 输出5 pp = &c2; pc1 = (Child1*)pp;// 强制转换,且类型发生变化,将造成错误 cout << pc1->i << endl;// 输出:1717986918 return 0; }
上面两个例子之所以引起类型不安全的问题,是因为程序员使用不得当。
void*
因此,想保证程序的类型安全性,应尽量避免使用空类型指针void*
,尽量不对两种类型指针做强制转换。
重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。举个例子:
class A{
//...
virtual int fun();
void fun(int);
void fun(double,double);
static int fun(char);
//...
};
重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且:
举个例子:
class Parent { public: virtual int fun(int a) { } }; class Son : public Parent { public: // 重写,一般加 override 可以确保是重写父类的函数 virtual int fun(int a) override { } };
重载与重写的区别:
隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:
// 父类 class Parent { public: void fun(int a) { cout << "Parent 中的 fun 函数" << endl; } }; // 子类 class Son : public Parent { public: // 隐藏父类的 fun 函数 void fun(int a) { cout << "Son 中的 fun 函数" << endl; } }; int main() { Son son; son.fun(2); // 调用的是Son中的fun函数 son.Parent::fun(2); // 调用Parent中的fun函数 return 0; }
// 父类 class Parent { public: virtual void fun(int a) { cout << "Parent 中的 fun 函数" << endl; } }; // 子类 class Son : public Parent { public: // 隐藏父类的 fun 函数 virtual void fun(char* a) { cout << "Son 中的 fun 函数" << endl; } }; int main() { Son son; son.fun(2); // 报错,调用的是 Son 中的 fun 函数,但是参数类型不对 son.Parent::fun(2); // 调用 Parent 中的 fun 函数 return 0; }
// 父类 class Parent{ public: virtual void fun(int a) {// 虚函数 cout << "This is Parent fun " << a << endl; } void add(int a,int b) { cout << "This is Parent add " << a + b << endl; } }; // 子类 class Son : public Parent { public: void fun(int a) override {// 覆盖 cout << "This is Son fun " << a << endl; } void add(int a) {// 隐藏 cout << "This is Son add " << a + a << endl; } }; int main() { //基类指针指向派生类对象时,基类指针可以直接调用到派生类的覆盖函数,也可以通过 :: 调用到基类被覆盖的虚函数;而基类指针只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数。 Parent* p = new Son; p->fun(1); // 调用子类 fun 覆盖函数 p->A::fun(1); // 调用父类 fun p->add(1,2); // 调用父类 add // p->add(1); // 错误,识别的是 A 类中的 add 函数,参数不匹配 // p->B::add(1); // 错误,无法识别子类 add 函数 }
C++中的构造函数可以分为4类:
举个例子:
#include<iostream> using namespace std; class Student { public: // 默认构造函数,没有参数 Student() { this->age = 20; this->num = 1000; } // 初始化构造函数,有参数和参数列表 Student(int a, int n) : age(a), num(n) { } // 拷贝构造函数,这里与编译器生成的一致 Student(const Student& s) { this->age = s.age; this->num = s.num; } // 转换构造函数,形参是其他类型,其只有一个形参 Student(int r) { this->age = r; this->num = 1002; } ~Student() { } public: int age; int num; }; int main() { Student s1; Student s2(18, 1001); int a = 10; Student s3(a); Student s4(s3); printf("s1 age:%d, num:%d\n", s1.age, s1.num); printf("s2 age:%d, num:%d\n", s2.age, s2.num); printf("s3 age:%d, num:%d\n", s3.age, s3.num); printf("s2 age:%d, num:%d\n", s4.age, s4.num); return 0; } /* s1 age:20, num:1000 s2 age:18, num:1001 s3 age:10, num:1002 s2 age:10, num:1002 */
浅拷贝
浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。
深拷贝
深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。
#include<iostream> #include<string.h> using namespace std; class Student { private: int num; char* name; public: Student() { name = new char(20); cout << "Student" << endl; } ~Student() { cout << "~Student " << &name << endl; delete name; name = NULL; } Student(const Student& s) { // 拷贝构造函数 // 浅拷贝,当前对象的name和传入对象的name指向相同的地址 //name = s.name; // 深拷贝 name = new char(20); memcpy(name, s.name, strlen(s.name)); cout << "copy Student" << endl; } }; int main() { // 花括号让 s1和s2变成局部对象,方便测试 { Student s1; Student s2(s1); // 调用拷贝构造函数,复制对象 } system("pause"); return 0; } /* //浅拷贝执行结果: Student copy Student ~Student 0x7ffc7e932e08 ~Student 0x7ffc7e932e18 *** Error in `./a.out': double free or corruption (fasttop): 0x0000000002369c20 *** //深拷贝执行结果: Student copy Student ~Student 0x7fff9a1be258 ~Student 0x7fff9a1be268 */
从执行结果可以看出,浅拷贝在对象的拷贝创建时存在风险,即被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。
内联函数适用场景:
派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性在派生过程中也是可以调整的,三种派生方式的访问权限如下表所示:注意外部访问并不是真正的外部访问,而是在通过派生类的对象对基类成员的访问。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iNIMxAdT-1668393620910)(1-C++基础.assets/202205212341241.png)]
派生类对基类成员的访问形象有如下两种:
公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问
保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的,访问规则如下表
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wTBDOQNe-1668393620911)(1-C++基础.assets/202205212341074.png)]
私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承,访问规则如下表
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mdNoVbuM-1668393620911)(1-C++基础.assets/202205212341430.png)]
一、访问权限
访问权限 | 外部 | 派生类 | 内部 |
---|---|---|---|
public | ✔ | ✔ | ✔ |
protected | ❌ | ✔ | ✔ |
private | ❌ | ❌ | ✔ |
public、protected、private 的访问权限范围关系:
public > protected > private
大端存储:字数据的高字节存储在低地址中
小端存储:字数据的低字节存储在低地址中
例如:32bit 的数字 0x12345678
所以在 Socket 编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输
小端模式中的存储方式为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wuumSnpF-1668393620911)(1-C++基础.assets/202205071832785.png)]
大端模式中的存储方式为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Xx5VC8W-1668393620912)(1-C++基础.assets/202205071832707.png)]
了解了大小端存储的方式,如何在代码中进行判断呢?下面介绍两种判断方式:
#include<iostream>
using namespace std;
int main()
{
int a = 0x1234;
// 由于 int 和 char 的长度不同,借助 int 型转换成 char 型只会留下低地址的那部分
char c = (char)(a);
if(c == 0x12) // 低地址存储高字节
cout << "大端存储" << endl;
else if(c == 0x34) // 低地址存储低字节
cout << "小端存储" << endl;
}
#include<iostream> using namespace std; // union 联合体的重叠式存储,endian联合体占用内存的空间为每个成员字节长度的最大值 union endian { int a; char ch; }; int main() { endian value; value.a = 0x1234; //a和ch共用4字节的内存空间 if(value.ch == 0x12) cout << "大端存储" << endl; else if(value.ch == 0x34) cout << "小端存储" << endl; }
(1)volatile
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。
volatile 定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量
需要定义为 volatile 类型。
volatile 指针
volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念
修饰由指针指向的对象、数据是 const 或 volatile 的:
const char* cpch;
volatile char* vpch;
指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:
char* const pchc;
char* volatile pchv;
注意:
多线程下的 volatile
有些变量是用 volatile 关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用 volatile 声明,**该关键字的作用是防止优化编译器把变量从内存装入 CPU 寄存器中。**如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile 的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
(2)mutable
mutable 的中文意思是“可变的,易变的”,跟 constant(即 C++ 中的 const )是反义词。在 C++ 中,mutable 也是为了突破 const 的限制而设置的。被 mutable 修饰的变量,将永远处于可变的状态,即使在一个 const 函数中。
我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成 const 的。但是,有些时候,我们需要在 const 函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被 mutable 来修饰,并且放在函数后后面关键字位置。
样例
class Person { int m_A; mutable int m_B; // 特殊变量,在常函数里值也可以被修改 public: void add() const { m_A = 10; // 错误,不可修改值,this 已经被修饰为常量指针 m_B = 20; // 正确 } }; class Person { public: int m_A; mutable int m_B; // 特殊变量,在常函数里值也可以被修改 }; int main() { const Person p; // 修饰常对象,不可修改类成员的值 // 修饰常对象,不可修改类成员的值 p.m_A = 10; // 错误,被修饰了指针常量 p.m_B = 200; // 正确,特殊变量,修饰了 mutable }
(3)explicit
explicit 关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换,注意以下几点:
#include<iostream> using namespace std; class Test1 { public: /* C++提供了关键字explicit,可以阻止不应该允许的经过转换构造函数进行的隐式转换的发生, 声明为explicit的构造函数不能在隐式转换中使用。 C++中, 一个参数的构造函数(或者除了第一个参数外其余参数都有默认值的多参构造函数), 承担了两个角色。 1 是个构造;2 是个默认且隐含的类型转换操作符。 所以, 有时候在我们写下如 AAA = XXX, 这样的代码, 且恰好XXX的类型正好是AAA单参数构造器的参数类型, 这时候编译器就自动调用这个构造器,创建一个AAA的对象。 这样看起来好象很酷, 很方便。 但在某些情况下, 却违背了程序员的本意。 这时候就要在这个构造器前面加上explicit修饰, 指定这个构造器只能被明确的调用/使用, 不能作为类型转换操作符被隐含的使用。 解析:explicit构造函数是用来防止隐式转换的。 */ Test1(int num) : n(num) { cout << "Test1 构造函数" << endl; } private: int n; }; class Test2 { public: explicit Test2(int num) : n(num) { cout << "Test2 构造函数" << endl; } private: int n; }; int main() { // 自动调用 Test1 的构造函数 Test1 t1 = 12; // Test1 构造函数 Test2 t2(13); // Test2 构造函数 //Test2 t3 = 14;// Error,不存在从 int 转换到 Test2 的适当构造函数 return 0; }
另:第三种情况在Linux g++ 下则不会发生拷贝构造函数,不仅如此即使返回局部对象的引用,依然不会发生拷贝构造函数
总结就是:即使发生 NRV 优化的情况下,Linux+ g++的环境是不管值返回方式还是引用方式返回的方式都不会发生拷贝构造函数,而 Windows + VS2019 在值返回的情况下发生拷贝构造函数,引用返回方式则不发生拷贝构造函数。
在 C++ 编译器发生 NRV 优化,如果是引用返回的形式则不会调用拷贝构造函数,如果是值传递的方式依然会发生拷贝构造函数。
在VS2019下进行下述实验:
举个例子:
#include<iostream> using namespace std; class A { public: A() { } A(const A& a) { cout << "copy contructor is called" << endl; } ~A() { } }; void useClassA(A a) { } // 此时会发生拷贝构造函数的调用,虽然发生 NRV 优化,但是依然调用拷贝构造函数 A getClassA() { A a; return a; } // VS2019下,此时编辑器会进行(Named return Value优化)NRV优化,不调用拷贝构造函数 , // 如果是引用传递的方式返回当前函数体内生成的对象时,并不发生拷贝构造函数的调用 A& getClassA2() { A a; return a; } int main() { A a1, a3,a4; A a2 = a1; // 调用拷贝构造函数,对应情况1 useClassA(a1); // 调用拷贝构造函数,对应情况2 a3 = getClassA(); //发生 NRV 优化,但是值返回,依然会有拷贝构造函数的调用,对应情况3 a4 = getClassA2(); // 发生 NRV 优化,且引用返回自身,不会调用 return 0; }
情况1比较好理解
情况2的实现过程是,调用函数时先根据传入的实参产生临时对象,再用拷贝构造去初始化这个临时对象,在函数中与形参对应,函数调用结束后析构临时对象
情况3在执行return时,理论的执行过程是:产生临时对象,调用拷贝构造函数把返回对象拷贝给临时对象,函数执行完先析构局部变量,再析构临时对象, 依然会调用拷贝构造函数
下面程序在 Visual Studio 2019里面的结果是:
good bye 3
0
good bye 3
good bye 2
good bye 1
但是在 g++ 下编译运行结果是:
0
good bye 3
good bye 2
good bye 1
同样的程序在不同的编译器下的执行结果不一样,那么,只有一种解释:编译器在幕后给你干了一些事情。
我想到了《深度探索C++对象模型》中的NRV(Named Return Value)优化。
对于函数:
Trans operator + (const Trans& t1, const Trans& t2)
{
Trans t(3);
t.a = t1.a + t2.a;
return t;
}
编译器会将它转换为类似这样:
void operator +(Trans *this, const Trans& t1, const Trans& t2, Trans &__result)
{
Trans t(3);
t.a = t1.a + t2.a;
__result(t);
return;
}
以上的代码在变量和函数名上并没有像编译器一样进行转换,这里只关注编译器是如何返回对象的。
如果编译器采用NVR优化,代码就会被转换成类似这样:
void operator +(Trans *this, const Trans& t1, const Trans& t2, Trans &__result)
{
__result.Trans::Trans(3);
__result.a = t1.a + t2.a;
return;
}
因此,如果不采用NVR优化,在返回一个对象时,会再创建一个临时对象用于获取返回值,因此,此函数会产生两个对象,如果采用NVR优化,在返回一个对象时,直接用返回值去取代函数内部的局部对象,此时,函数只产生一个对象。所以,对于VS和GCC的不同,或许(这只是我的一种解释这种行为的想法)可以理解为VS没有采用NVR优化,所以会有两个对象析构,而GCC采用了NVR优化,所以只有一个对象被析构。
#include <iostream> #include <string> using namespace std; class Trans { public: Trans() : a(12) {}; Trans(int id) : a(0), i(id) {}; ~Trans() { cout << "good bye " << i << endl; } friend std::ostream& operator<<(ostream&, const Trans&); friend Trans operator + (const Trans&, const Trans&); private: int a; int i; }; std::ostream& operator<<(ostream& os, const Trans& tra) { return os << tra.a << endl; } Trans operator + (const Trans& t1, const Trans& t2) { Trans t(3); t.a = t1.a + t2.a; return t; } int main() { Trans p(1), q(2); cout << p + q; return 0; }
在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new
言下之意就是普通的new,就是我们常用的new,在C++中定义如下:
void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();
因此 plain new 在空间分配失败的情况下,抛出异常 std::bad_alloc 而不是返回 NULL ,因此通过判断返回值是否为 NULL 是徒劳的,举个例子:
#include<iostream> #include<string> using namespace std; int main() { try { char* p = new char[10e11]; delete p; } catch(const std::bad_alloc &ex) { cout << ex.what() << endl; } return 0; } //执行结果:bad allocation
nothrow new 在空间分配失败的情况下是不抛出异常,而是返回 NULL ,定义如下:
void* operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();
举个例子:
#include<iostream>
#include<string>
using namespace std;
int main()
{
char* p = new(nothrow) char[10e11];
if(p == NULL)
{
cout << "alloc failed" << endl;
}
delete p;
return 0;
}
//运行结果:alloc failed
这种 new 允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new 不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:
void* operator new(size_t,void*);
void operator delete(void*,void*);
使用 placement new 需要注意两点:
举个例子:
#include<iostream> #include<string> using namespace std; class ADT { int i; int j; public: ADT() { i = 10; j = 100; cout << "ADT construct i = " << i << ",j = " << j << endl; } ~ADT() { cout << "ADT destruct" << endl; } }; int main() { char* p = new(nothrow) char[sizeof ADT + 1]; if(p == NULL) { cout << "alloc failed" << endl; } // placement new:不必担心失败,只要 p 所指对象的空间足够ADT创建即可 ADT *q = new(p) ADT; // delete q; // 错误,不能在此处调用 delete q; q->ADT::~ADT(); // 显示调用析构函数 delete[] p; return 0; } /* ADT construct i = 10,j = 100 ADT destruct */
在程序执行过程中,由于程序员的疏忽或是系统资源紧张等因素都有可能导致异常,任何程序都无法保证绝对的稳定,常见的异常有:
如果不及时对这些异常进行处理,程序多数情况下都会崩溃。
C++中的异常处理机制主要使用 try、**throw **和 catch 三个关键字,其在程序中的用法如下:
#include<iostream> using namespace std; int main() { double m = 1, n = 0; try { cout << "before dividing." << endl; if (n == 0) throw -1; // 抛出 int 型异常 else if (m == 0) throw -1.0; // 抛出 double 型异常 else cout << m / n << endl; cout << "after dividing." << endl; } catch (int d) { cout << "catch (int)" << d << endl; } catch (double d) { cout << "catch (double)" << d << endl; } catch (...) { cout << "catch (...)" << endl; } cout << "finished" << endl; return 0; } /* before dividing. catch (int)-1 finished */
代码中,对两个数进行除法计算,其中除数为 0 。可以看到以上三个关键字,程序的执行流程是先执行 try 包裹的语句块,如果执行过程中没有异常发生,则不会进入任何 catch 包裹的语句块,如果发生异常,则使用 throw 进行异常抛出,再由 catch 进行捕获,throw 可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常 class 。**catch 根据 throw 抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用 catch(…) 的方式捕获任何异常(不推荐)。**当然,如果 catch 了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在 catch 里面再 throw 异常。
有时候,程序员在定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:
int fun() throw(int,double,A,B,C) {
//...
}
这种写法表名函数可能会抛出 int , double 型或者 A、B、C 三种类型的异常,如果 throw 中为空,表明不会抛出任何异常,如果没有 throw 则可能抛出任何异常
C++ 标准库中有一些类代表异常,这些类都是从 exception 类派生而来的,如下图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FLkrjQsA-1668393620912)(1-C++基础.assets/202205212342667.png)]
#include<iostream> #include<typeinfo> using namespace std; class A { public: virtual ~A(); }; int main() { A* a = NULL; //A* a = new A; try { cout << typeid(*a).name() << endl; } catch (bad_typeid) { cout << "Object is NULL" << endl; } return 0; } /* Object is NULL */
在面向对象的C++语言中,虚函数(virtual function)是一个非常重要的概念。
什么是虚函数:虚函数是指一个类中你希望重载的成员函数 ,当你用一个 基类指针或引用 指向一个继承类对象的时候,调用一个虚函数时, 实际调用的是继承类的版本。
#include <iostream> using namespace std; class Parent { public: char data[20]; void Function1(); virtual void Function2(); // 这里声明Function2是虚函数 }parent; void Parent::Function1() { printf("This is parent,function1\n"); } void Parent::Function2() { printf("This is parent,function2\n"); } class Child :public Parent { void Function1(); void Function2(); } child; void Child::Function1() { printf("This is child,function1\n"); } void Child::Function2() { printf("This is child,function2\n"); } int main(int argc, char* argv[]) { Parent* p; // 定义一个基类指针 if (getchar() == 'c') // 如果输入一个小写字母c p = &child; // 指向继承类对象 else p = &parent; // 否则指向基类对象 p->Function1(); // 这里在编译时会直接给出Parent::Function1()的入口地址。 p->Function2(); // 注意这里,执行的是哪一个Function2? return 0; }
用任意版本的 Visual Studio 编译并运行,输入一个小写字母 c ,得到下面的结果:
c
This is parent,function1
This is child,function2
为什么会有第一行的结果呢?因为我们是用一个 Parent 类的指针调用函数 Fuction1() ,虽然实际上这个指针指向的是 Child 类的对象,但编译器无法知道这一事实(直到运行的时候,程序才可以根据用户的输入判断出指针指向的对象),它只能按照调用 Parent 类的函数来理解并编译,所以我们看到了第一行的结果。
那么第二行的结果又是怎么回事呢?我们注意到,Function2() 函数在基类中被 virtual 关键字修饰,也就是说,它是一个虚函数。虚函数最关键的特点是“动态联编”,它可以在运行时判断指针指向的对象,并自动调用相应的函数。
如果我们在运行上面的程序时任意输入一个非c的字符,结果如下:
a
This is parent,function1
This is parent,function2
请注意看第二行,它的结果出现了变化。程序中仅仅调用了一个 Function2() 函数,却可以根据用户的输入自动决定到底调用基类中的 Function2 还是继承类中的 Function2 ,这就是虚函数的作用。
一定要注意“静态联编”和“ 动态联编”的区别;
对于我来说,若没有亲自去测试,凭自己的感觉,当在键盘中输入 “c” 时,我会觉得由于有 p=&child; 这一句代码,我会认为结果都是:
This is child,function2
This is child,function2
但是结果却是:
This is parent,function1
This is child,function2
因为虽然实际上这个指针指向的是 Child 类的对象,但编译器无法知道这一事实,它只能按照调用 Parent 类的函数来理解并编译,所以我们看到了第一行的结果。
第二行中调用了子类的 Function2 ,完全是因为 virtual 的功能,virtual 实现了动态联编,它可以在运行时判断指针指向的对象,并自动调用相应的函数。
p=&parent; //这一句,该指针很明显的是指向父类,那么肯定调用的是父类的方法
1、先来介绍它的第一条也是最重要的一条:隐藏。(static 函数,static 变量均可)
当同时编译多个文件时,所有未加 static 前缀的全局变量和函数都具有全局可见性。
2、static 的第二个作用是保持变量内容的持久。( static 变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和 static 变量,只不过和全局变量比起来,static 可以控制变量的可见范围,说到底 static 还是用来隐藏的。
3、static 的第三个作用是默认初始化为 0(static 变量)
其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是 0x00 ,某些时候这一特点可以减少程序员的工作量。
4、static 的第四个作用:C++ 中的类成员声明 static
类内:
int *const p2
中 const 修饰 p2 的值,所以理解为 p2 的值不可以改变,即 p2 只能指向固定的一个变量地址,但可以通过 *p2 读写这个变量的值。顶层指针表示指针本身是一个常量int const *p1
或者const int *p1
两种情况中 const 修饰*p1
,所以理解为*p1
的值不可以改变,即不可以给*p1
赋值改变p1
指向变量的值,但可以通过给 p 赋值不同的地址改变这个指针指向。底层指针表示指针所指向的变量是一个常量。
has-A 包含关系,用以描述一个类由多个部件类构成,实现 has-A 关系用类的成员属性表示,即一个类的成员属性是另一个已经定义好的类;
use-A,一个类使用另一个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的方式来实现;
is-A ,继承关系,关系具有传递性;
所谓的继承就是一个类继承了另一个类的属性和方法,这个新的类包含了上一个类的属性和方法,被称为子类或者派生类,被继承的类称为父类或者基类;
子类拥有父类的所有属性和方法,子类可以拥有父类没有的属性和方法,子类对象可以当做父类对象使用;
public、protected、private
#include<iostream> using namespace std; class animal{ private: int weight; public: animal(){ weight = 0; cout << "in the default constructor of the animal" << endl; } animal(int _weight){ weight = _weight; cout << "in the constructor of the animal" << endl; } ~animal(){ cout << "in the destructor of the animal" << endl; } }; class catamount{ private: int weight; public: catamount(){ weight = 0; cout << "in the default constructor of the catamout" << endl; } catamount(int _weight){ weight = _weight; cout << "in the constructor of the catamount" << endl; } ~catamount(){ cout << "in the destructor of the catamount" << endl; } }; class tiger:public catamount, public animal{ private: int weight; public: tiger():catamount(),animal(){ weight = 0; cout << "in the default constructor of the tiger" << endl; } tiger(int weight1, int weight2, int weight3):catamount(weight2), animal(weight3){ cout << "in the constructor of the tiger" << endl; weight = weight3; } ~tiger(){ cout << "in the destructor of the tiger" << endl; } }; int main(){ tiger mTiger(60, 60, 60); return 0; } /* in the constructor of the catamount in the constructor of the animal in the constructor of the tiger in the destructor of the tiger in the destructor of the animal in the destructor of the catamount */
类的兼容性是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代,通过公有继承,派生类得到了基类中出构造函数、析构函数之外的所有成员。这样,公有派生类实际上就具有了基类的所有功能,凡是基类可以解决的问题,公有派生类都可以解决。继承类有以下五个原则:
1):子类对象可以当做父类对象使用
2):子类对象可以直接赋值给父类对象
3):子类对象可以直接初始化父类对象
4):父类指针可以直接指向子类对象
5):父类引用可以直接引用子类对象
# include <iostream> using namespace std; class Animal { public: void sleep() { cout << "Animal sleep" << endl; } }; class Dog :public Animal { void bite() { cout << "Dog bite" << endl; } }; int main(int argc, char** argv) { //1 子类对象可以当做父类对象使用 Dog dog; dog.sleep();//子类对象可以调用父类方法 //2 子类对象可以直接赋值给父类对象 Animal a; a = dog; //3 子类对象可以直接初始化父类对象 Animal b = dog; //Dog dog1 = a; //父类对象不可以直接初始化子类对象 //4 父类指针可以直接指向子类对象 Animal * c = new Dog; //Dog * dog3 = new Animal;//子类指针不可以指向父类对象 //5 父类引用可以直接引用子类对象 Animal & d = dog; return 0; }
9: int x = 1;
00401048 mov dword ptr [ebp-4],1
10: int &b = x;
0040104F lea eax,[ebp-4]
00401052 mov dword ptr [ebp-8],eax
x 的地址为 ebp-4 ,b 的地址为 ebp-8 ,因为栈内的变量内存是从高往低进行分配的,所以 b 的地址比 x 的低。
lea eax,[ebp-4]
这条语句将 x 的地址ebp-4
放入eax
寄存器
mov dword ptr [ebp-8],eax
这条语句将eax
的值放入 b 的地址
ebp-8 中上面两条汇编的作用即:将 x 的地址存入变量 b 中,这不和将某个变量的地址存入指针变量是一样的吗?所以从汇编层次来看,的确引用是通过指针来实现的。
浅复制 :只是拷贝了基本类型的数据,而引用类型数据,复制后也是会发生引用,我们把这种拷贝叫做“(浅复制)浅拷贝”,换句话说,浅复制仅仅是指向被复制的内存地址,如果原地址中对象被改变了,那么浅复制出来的对象也会相应改变。
深复制 :在计算机中开辟了一块新的内存地址用于存放复制的对象。
在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如 A=B 。这时,如果 B 中有一个成员变量指针已经申请了内存,那 A 中的那个成员变量也指向同一块内存。这就出现了问题:当 B 把内存释放了(如:析构),这时 A 内的指针就是野指针了,出现运行错误。
1、 new/delete 是 C++ 关键字,需要编译器支持。malloc/free 是库函数,需要头文件支持;
2、 使用 new 操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而 malloc 则需要显式地指出所需内存的尺寸。
3、 new 操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故 new 是符合类型安全性的操作符。而 malloc 内存分配成功则是返回 void *
,需要通过强制类型转换将 void*
指针转换成我们需要的类型。
4、 new 内存分配失败时,会抛出 bac_alloc 异常。malloc 分配内存失败时返回 NULL。
5、 new 会先调用 operator new 函数,申请足够的内存(通常底层使用 malloc 实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete 先调用析构函数,然后调用 operator delete 函数释放内存(通常底层使用 free 实现)。malloc/free 是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
1、 动态数组管理 new 一个数组时,[] 中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;
2、 new 动态数组返回的并不是数组类型,而是一个元素类型的指针;
3、 delete[] 时,数组中的元素按逆序的顺序进行销毁;
4、 new 在内存分配上面有一些局限性,new 的机制是将内存分配和对象构造组合在一起,同样的,delete 也是将对象析构和内存释放组合在一起的。allocator 将这两部分分开进行,allocator 申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。
首先什么是Allocator?Allocator有什么用?
分配器是负责封装堆内存管理的对象,它们在整个标准库中使用,特别是 STL 容器使用它们来管理r容器内部的所有内存分配,大部份情况下,程序员不用理会,标准容器使用默认的分配器称为 std::allocator ,例如当你声明一个简单的 vector 对象时,C++ 编译器默认已经使用了内置的 std::allocator ,在标准库的 vector 模板当中,第二个模板参数 _Alloc 就是 std::allocator ,实际上, std::allocator 也是一个类模板。(https://zhuanlan.zhihu.com/p/185611161)
int main(){
std::vector<int> a;
}
template <class _Ty, class _Alloc = allocator<_Ty>>
class vector { // varying size array of values
private:
template <class>
friend class _Vb_val;
friend _Tidy_guard<vector>;
using _Alty = _Rebind_alloc_t<_Alloc, _Ty>;
using _Alty_traits = allocator_traits<_Alty>;
public:
static_assert(!_ENFORCE_MATCHING_ALLOCATORS || is_same_v<_Ty, typename _Alloc::value_type>,
_MISMATCHED_ALLOCATOR_MESSAGE("vector<T, Allocator>", "T"));
//...
};
先简要回顾一下历史。Alexander Stepanov(STL之父) 提出了分配器的想法,其动机是使容器完全独立于底层内存模型。他打算让分配器完全封装内存模型,但标准委员会发现这很危险,因为这种方法会导致不可接受的效率下降。因此,分配器的当前用途是让程序员控制容器内的内存分配,而不是采用底层硬件的地址模型
从零实现自己的内存分配器,来理解std::allocator的内部运行机制,自定义allocator有很多现实的原因。
每个容器实例中都有一个 Allocator 实例。它向分配器请求存储来存储元素。分配器应具备的基本成员函数如下:
标准库中的 allocator 还有一些可选的成员,视不同 C++ 编译器版本而异。
我们下面从 0 实现一个符合定义的 allocator ,首先我们要在调用层代码,在声明一个 vector 实例,显式传入我们自定义的 MyAllocator ,并使用他来分配内存
int main(void){
std::vector<int,MyAllocator<int>> v;
}
下面是我们对 MyAllocator 的定义,并且定义了标准库规定的所有类型别名,下面的类型别名在标准库的 std::allocator_traints 中都提供了默认值,因此这里列出所有类型别名仅仅为了完整说明一个 allocator 的实现过程。
template<class T>
class MyAllocator{
public:
using value_type T;
using pointer=T*;
using const_pointer=const T*;
using void_pointer=void*;
using const_void_pointer=const void*;
using size_type size_t;
using difference_type=std::ptrdiff_t
MyAllocator()=default;
~MyAllocator()=default;
};
我们跟着下来需要实现的是allocate成员函数和dealloc成员函数,整个allocator的堆内存管理都围绕这两个接口展开。我们需要对 C++ 的 allocator 的堆内存接口调用顺序有个清晰的认识,如下图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ceiCOZoj-1668393620912)(1-C++基础.assets/v2-59ed19947f0f359e9a7e1ec585958676_720w.webp)]
我们在 MyAllocator 实现的 allocate 和 deallocate 函数是必须的,
template<class T>
class MyAllocator{
public:
.....
pointer allocate(size_type numObjs){
return static_cast<pointer>(operator new(sizeof(T)*numObjs));
}
pointer allocate(size_type numObjs,const_void_pointer hit){
return allocate(numObjs);
}
void deallocate(pointer p,size_type numObjs){
operator delete(p);
}
}
我们这里也定义了另一个 allocate 版本的成员函数,接受一个 numObjs 参数,并接受一个已分配堆内存的指针,他是一个只想最近分配的元素的指针。可以使用他来改进已分配内存的释放,只是为了提供缓存性能,在我们的示例中,我们会忽略它并立即返回。
其他的成员函数实现
size_type max_size() const{
return std::numeric_limits<size_type>::max();
}
对象构造的问题
这是 C++20 中,被丢弃的内容,但为了完整说明,我在本篇中仍然会提及该内容。
C++20 之前的标准库,在 allocator 中,我们有两个的方法用于构造和销毁对象,一种叫 construct ,用于在我们分配的内存中构造类型 T 的实际对象。需要注意的是,当我们调用 new 操作符分配了原始内存,new 操作符并不会执行任何类型 T 的初始化.
我们看看构造函数是如何工作的,首先,对于 allocator 下的 construct 方法,它也是一个类模板的成员函数,如下代码定义,该成员函数接受一个类型 U 的指针 p (已分配了堆内存),construct 的运行原理非常简单,他在 construct 函数内部原地调用类型 U 的构造函数,并且转发外部的任意参数传递给类型 U 的构造函数
template<class T>
class MyAllocator{
public:
//类型别名的代码省略
//其余代码略
//构造函数
template<class U,class...Args>
void construct(U *p,Args &&...){
new(p) U(std::forward<Args>(args)...);
}
}
同理的 destroy 接口实现如下
template<class T>
class MyAllocator{
public:
//类型别名的代码省略
//其余代码略
//构造函数
template<class U,class...Args>
void destroy(U *p){
p->~U();
}
}
这两个函数默认情况下是完全可选,我这里只是展示如何完整的 MyAllocator 的自定义实现。
上面实现的必选项和可选项的 allocator 的类成员,都是标准库已经规定的接口名称,没什么好说的。而 allocator 的难点就是 rebind 这个内部类成员,而且它也是一个类模板。
rebind 内部类成员
根据标准库的定义,rebind 被定义为 std::allocator 类的结构成员; 此结构定义了 other 成员,该成员定义为专门用于不同参数类型的分配器的实例( other 成员定义了可以创建不同类型的对象的分配器类)
class MyAllocator{
public:
.....
template<class U>
struct rebind{using other=MyAllocator<U>;}
}
std::MyAllocator 模板用于获取某种类型T的对象。 容器内部可能需要分配不同类型的对象。 例如,当您有一个std::list<T,MyAllocator>
时,分配器MyAllocator
用于分配类型 T 的对象,而std::list<T,MyAllocator>
实际上需要分配某个节点类型Node<T>
的对象,从模板函数的角度来考虑,我们通常会用另为一个模板参数 U 来表示 调用节点类型Node<T>
,std::list<T,MyAllocator>
需要获得类型 U 对象的分配器,该分配器使用 MyAllocator 提供的分配机制。对于程序员在定义自己的数据结构时,需要在自己实现的容器内部定义类似如下的语句
typename MyAllocator::template rebind<U>::other
指定相应的类型。 现在,此声明中有一些语法上令人困惑的地方:
那么,我们在自定义的数据结构当中可能多次用到类 U 类型的 allocator 实例,那么我们可以在自定义的数据结构容器类的 private 作用域定义一个类型别名
typedef typename MyAllocator::template rebind<U>::other Elem_alloc_type
关于,MyAllocator 说到这里应该是比较全面的了,他的全貌大概如下代码,若要确认该自定义的MyAllocator 和 std::allocator 一样能够对所有 std 的容器起到作用,不妨在增加一些计数器的数据成员
#include <iostream> #include <limits> #include <stdlib.h> template <class T> class MyAllocator { public: //下面是个类型别名,实现中的可选项 using value_type = T; using pointer = T *; using const_pointer = const T *; using void_pointer = void *; using const_void_pointer = const void *; using size_type = size_t; using difference = std::ptrdiff_t; //重新绑定函数 template <class U> struct rebind { using other = MyAllocator<U>; }; MyAllocator() = default; ~MyAllocator() = default; //分配内存 pointer allocate(size_type numObjects) { allocCount += numObjects; std::cout << "MyAllocator::allocate,内存分配:" << numObjects << std::endl; return static_cast<pointer>(operator new(sizeof(T) * numObjects)); } //分配内存 pointer allocate(size_type numObjects, const_void_pointer hint) { return allocate(numObjects); } //释放内存 void deallocate(pointer p, size_type numObjects) { std::cout << "MyAllocator::deallocate,内存释放:" << numObjects << std::endl; allocCount = allocCount - numObjects; operator delete(p); } //分配器支持最大的内存数 size_type max_size() const { return std::numeric_limits<size_type>::max(); } // //构造对象 // template <class U, class... Args> // void construct(U *p, Args &&... args) // { // new (p) U(std::forward<Args>(args)...); // } // //销毁对象 // template <class U> // void destroy(U *p) // { // p->~U(); // } //返回每次分配/删除的内存数 size_type get_allocations() const { return allocCount; } private: //统计当前内存的使用量 size_type allocCount; };
我们的调用代码基本如下
#include "headers/Allocator.hh" #include <cinttypes> #include <iostream> #include <stdlib.h> #include <unistd.h> #include <vector> int main() { std::vector<int, MyAllocator<int>> v(0); for (size_t i = 0; i < 30; i++) { sleep(1); v.push_back(i); std::cout << "当前容器内存占用量:" << v.get_allocator().get_allocations() << std::endl; } }
用 vector 执行下面的测试如下…
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XYYphQFi-1668393620913)(1-C++基础.assets/v2-8070e54be20cce49ed7bd9fb71c06664_720w.webp)]
测试效果
这里没有生硬地罗列一堆 std::allocator 的 api ,而是采用原生实现的 MyAllocator 来说明 std::allocator 的内部机制。
最近看内存池有关的东西,又回顾了一下之前看 C++ Primer 时自己写的 vector ,发现只是对基本的 Vector 的成员函数进行了重写,最重要的地方也就是分配器用的都是默认的,所以内存分配这块之前就没弄清楚。
template<class _Ty,
class _Alloc = allocator<_Ty> >
class vector
: public _Vector_alloc<_Vec_base_types<_Ty, _Alloc> >
从 STL 中 vector 的源码可以看出,vector 分为两个部分,一个是参数类型 _Ty,这里的 class 跟 typename 是同一个东西,没有区别;还有一个是分配器 _Alloc ,默认是 allocator< _Ty > ,内存池实现就是在这里,自带的分配器只是简单的 new 一个内存,vector 实现的时候,设立三个指针:
private:
std::string *elements; //分配内存的头指针
std::string *first_free; //第一个空闲位置的指针
std::string *cap; //开的内存容量指针
std::allocator<std::string> alloc; //分配器
这样简单的判断size==capacity时,重新分配一个大小是原本两倍的内存,再使用移动赋值将原本的数据迁移到新内存中。
下面的表格是一个分配器所需实现的内存处理成员函数:
成员函数 | 简介 |
---|---|
allocate | 分配未初始化的存储 (公开成员函数) |
deallocate | 解分配存储 (公开成员函数) |
construct | 在分配的存储构造对象 (公开成员函数) |
destroy | 析构在已分配存储中的对象 (公开成员函数) |
通过实现重载这些成员函数可以实现内存池。
1、 new 简单类型直接调用 operator new 分配内存;
而对于复杂结构,先调用 operator new 分配内存,然后在分配的内存上调用构造函数;
对于简单类型,new[] 计算好大小后调用 operator new ;
对于复杂数据结构,new[] 先调用 operator new[] 分配内存,然后在 p 的前四个字节写入数组大小 n ,然后调用 n 次构造函数,针对复杂类型,new[] 会额外存储数组大小;
① new 表达式调用一个名为 operator new(operator new[]) 函数,分配一块足够大的、原始的、未命名的内存空间;
② 编译器运行相应的构造函数以构造这些对象,并为其传入初始值;
③ 对象被分配了空间并构造完成,返回一个指向该对象的指针。
2、 delete 简单数据类型默认只是调用 free 函数;复杂数据类型先调用 析构函数 再调用 operator delete;针对简单类型,delete 和 delete[] 等同。假设指针 p 指向 new[] 分配的内存。因为要 4 字节存储数组大小,实际分配的内存地址为 [p-4] ,系统记录的也是这个地址。delete[] 实际释放的就是 p-4 指向的内存。而 delete 会直接释放 p 指向的内存,这个内存根本没有被系统记录,所以会崩溃。
3、 需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
malloc/free 主要为了兼容 C ,new 和 delete 完全可以取代 malloc/free 的。
malloc/free 的操作对象都是必须明确大小的,而且不能用在动态类上。
new 和 delete 会自动进行类型检查和大小,malloc/free 不能执行构造函数与析构函数,所以动态对象它是不行的。
当然从理论上说使用 malloc 申请的内存是可以通过 delete 释放的。不过一般不这样写的。而且也不能保证每个 C++ 的运行时都能正常。
1、 在标准 C 库中,提供了 malloc/free 函数分配释放内存,这两个函数底层是由 brk、mmap、,munmap 这些系统调用实现的;
2、 brk 是将 数据段(.data) 的最高地址指针 _edata
往高地址推,mmap 是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系;
3、 malloc 小于 128k 的内存,使用 brk 分配内存,将 _edata
往高地址推;malloc 大于 128k 的内存,使用 mmap 分配内存,在堆和栈之间找一块空闲内存分配;brk 分配的内存需要等到高地址内存释放以后才能释放,而 mmap 分配的内存可以单独释放。当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过 128K ,于是内存紧缩。
4、 malloc 是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
void* malloc(unsigned int num_size);
int* p = malloc(20 * sizeof(int)); // 申请20个int类型的空间
void* calloc(size_t n,size_t size);
int* p = calloc(20,sizeof(int));
省去了人为空间计算;malloc 申请的空间的值是随机初始化的,calloc 申请的空间的值是初始化为 0 的;
void realloc(void* p,size_t new_size);
给动态分配的空间分配额外的空间,用于扩充容量。
这两种方式的主要区别在于:
对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。
列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。
① 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
③ 类类型的成员对象的构造函数(按照初始化顺序)
④ 派生类自己的构造函数。
① 当初始化一个引用成员时;
② 当初始化一个常量成员时;
③ 当调用一个基类的构造函数,而它拥有一组参数时;
④ 当调用一个成员类的构造函数,而它拥有一组参数时;
① 编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,并且在任何显示用户代码之前;
② list 中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的;
string 继承自 basic_string ,其实是对char*
进行了封装,封装的 string 包含了char*
数组,容量,长度等等属性。
string 可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2^n),然后将原字符串拷贝过去,并加上新增的内容。
内存泄露
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用 malloc,、realloc、 new 等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用 free 或 delete 释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了
避免内存泄露的几种方式
检测工具
对象复用
对象复用其本质是一种设计模式:Flyweight 享元模式。
通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。
零拷贝
零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。
零拷贝技术可以减少数据拷贝和共享总线操作的次数。
在 C++ 中,vector 的一个成员函数 emplace_back() 很好地体现了零拷贝技术,它跟 push_back() 函数一样可以将一个元素插入容器尾部,区别在于:使用 push_back() 函数需要调用拷贝构造函数和转移构造函数,而使用 emplace_back() 插入的元素原地构造,不需要触发拷贝构造和转移构造,效率更高。举个例子:
#include<vector> #include<string> #include<iostream> using namespace std; struct Person { string name; int age; // 初始构造函数 Person(string p_name, int p_age) : name(std::move(p_name)), age(p_age) { cout << "I have been constructed" << endl; } // 拷贝构造函数 Person(const Person& other) : name(std::move(other.name)), age(other.age) { cout << "I have been copy constructed" << endl; } // 转移构造函数 Person(Person&& other) : name(std::move(other.name)), age(other.age) { cout << "I have been moved" << endl; } }; int main() { vector<Person> e; cout << "emplace_back:" << endl; e.emplace_back("Jane", 23); // 不用构造类对象,直接调用构造函数 vector<Person> p; cout << "push_back:" << endl; p.push_back(Person("Mike", 35)); return 0; } /* emplace_back: I have been constructed push_back: I have been constructed I have been moved */
三大特性:继承、封装和多态
让某种类型对象获得另一个类型对象的属性和方法。
它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展
常见的继承有三种方式:
例如,将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉、走路等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法
数据和代码捆绑在一起,避免外界干扰和不确定性访问。
封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏,例如:将公共的数据或方法使用 public 修饰,而不希望被访问的数据或方法采用 private 修饰。
同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为**(重载实现编译时多态,虚函数实现运行时多态)**。
多态性是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单一句话:允许将子类类型的指针赋值给父类类型的指针
实现多态有二种方式:覆盖(override),重载(overload)。
覆盖:是指子类重新定义父类的虚函数的做法。
重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。例如:基类是一个抽象对象——人,那教师、运动员也是人,而使用这个抽象对象既可以表示教师、也可以表示运动员。
成员初始化列表的概念
在类的构造函数中,不在函数体内对成员变量赋值,而是在构造函数的花括号前面使用冒号和初始化列表赋值
效率
用初始化列表会快一些的原因是,对于类型,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用。而对于内置数据类型则没有差别。举个例子:
#include<iostream> using namespace std; class A { public: A() { cout << "默认构造函数A()" << endl; } A(int a) { value = a; cout << "A(int " << value << ")" << endl; } A(const A& a) { value = a.value; cout << "拷贝构造函数A(A& a): " << value << endl; } int value; }; class B { public: B() : a(1) { b = A(2); } A a; A b; }; int main() { B b; } /* A(int 1) 默认构造函数A() A(int 2) */
从代码运行结果可以看出,在构造函数体内部初始化的对象 b 多了一次构造函数的调用过程,而对象 a 则没有。由于对象成员变量的初始化动作发生在进入构造函数之前,对于内置类型没什么影响,但如果有些成员是类,那么在进入构造函数之前,会先调用一次默认构造函数,进入构造函数后所做的事其实是一次赋值操作(对象已存在),所以如果是在构造函数体内进行赋值的话,等于是一次默认构造加一次赋值,而初始化列表只做一次赋值操作。
reinterpret_cast<type-id> (expression)
type-id 必须是一个指针、引用、算术类型、函数指针或者成员指针。它可以用于类型之间进行强制转换。
const_cast<type_id> (expression)
该运算符用来修改类型的 const 或 volatile 属性。除了 const 或 volatile 修饰之外, type_id 和 expression 的类型是一样的。用法如下:
static_cast<type-id> (expression)
该运算符把 expression 转换为 type-id 类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:
注意:static_cast 不能转换掉 expression 的 const 、volatile 、或者 __unaligned 属性。
有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全
dynamic_cast<type-id> (expression)
该运算符把 expression 转换成 type-id 类型的对象。type-id 必须是类的指针、类的引用或者 void*
如果 type-id 是类指针类型,那么 expression 也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用
dynamic_cast 运算符可以在执行期决定真正的类型,也就是说 expression 必须是多态类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果 如果下行转换不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)
dynamic_cast 主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换
在类层次间进行上行转换时,dynamic_cast 和 static_cast 的效果是一样的
在进行下行转换时,dynamic_cast 具有类型检查的功能,比 static_cast 更安全
举个例子:
#include<iostream> using namespace std; class Base { public: Base() : b(1) { } virtual void fun() { } int b; }; class Son : public Base { public: Son() : d(2) { } int d; }; int main() { int n = 97; // reinterpret_cast int* p = &n; // 以下两种效果相同 char* c = reinterpret_cast<char*>(p); char* c2 = (char*)(p); cout << "reinterpret_cast输出:" << *c2 << endl; // const_cast const int* p2 = &n; int* p3 = const_cast<int*>(p2); *p3 = 100; cout << "const_cast输出:" << *p3 << endl; Base* b1 = new Son; Base* b2 = new Base; // static_case Son* s1 = static_cast<Son*>(b1); // 同类型安全转换 Son* s2 = static_cast<Son*>(b2); // 下行转换,不安全 cout << "static_cast输出:" << endl; cout << s1->d << endl; cout << s2->d << endl; // 下行转换,原先父对象没有 d 成员,输出垃圾值 // dynamic_cast Son* s3 = dynamic_cast<Son*>(b1); // 同类型转换 Son* s4 = dynamic_cast<Son*>(b2); // 下行转换,安全 cout << "dynamic_cast输出:" << endl; cout << s3->d << endl; if(s4 == nullptr) cout << "s4指针为 nullptr" << endl; else cout << s4->d << endl; return 0; } /* reinterpret_cast输出:a const_cast输出:100 static_cast输出: 2 -33686019 dynamic_cast输出: 2 s4指针为 nullptr */
从输出结果可以看出,在进行下行转换时,dynamic_cast 安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用 static_cast 下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。
从代码入手,解释这个过程:
#include<iostream> using namespace std; int f(int n) { cout << n << endl; return n; } void func(int param1,int param2) { int var1 = param1; int var2 = param2; // 如果 printf 换为 cout 进行输出,输出结果刚好相反 printf("var1 = %d,var2 = %d",f(var1),f(var2)); } int main(int argc,char* argv[]) { func(1,2); return 0; } /* 2 1 var1 = 1,var2 = 2 */
当函数从入口函数 main 函数开始执行时,编译器会将我们操作系统的运行状态,main 函数的返回地址、main 的参数、main 函数中的变量、进行依次压栈;
当 main 函数开始调用 func() 函数时,编译器此时会将 main 函数的运行状态进行压栈,再将 func() 函数的返回地址、func() 函数的参数从右到左、func() 定义变量依次压栈;
当 func() 调用 f() 的时候,编译器此时会将 func() 函数的运行状态进行压栈,再将的返回地址、f()函数的参数从右到左、f() 定义变量依次压栈
从代码的输出结果可以看出,函数 f(var1)、f(var2) 依次入栈,而后先执行 f(var2) ,再执行 f(var1) ,最后打印整个字符串,将栈中的变量依次弹出,最后主函数返回。
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。
数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。
当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。
coredump 是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做 core 的文件,这个 core 文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。
以下例子在 Linux 上编写一段代码并导致 segment fault 并产生 core 文件
mkdir coredumpTest
vim coredumpTest.cpp
在编辑器内键入
#include<iostream>
int main()
{
int i;
scanf("%d",i); // 正确的应该是 &i,这里使用 i 会导致 segment fault
printf("%d\n",i);
return 0;
}
编译
g++ coredumpTest.cpp -g -o coredumpTest
运行
./coredumpTest
使用 gdb 调试 coredump
gdb [可执行文件名] [core文件名]
1、我们用对象 a 初始化对象 b ,后对象 a 我们就不在使用了,但是对象 a 的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把 a 对象的内容复制一份到 b 中,那么为什么我们不能直接使用 a 的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
2、拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如 a->value )置为 NULL ,这样在调用析构函数的时候,由于有判断是否为 NULL 的语句,所以析构 a 的时候并不会回收 a->value 指向的空间;
3、移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个 move 语句,就是将一个左值变成一个将亡值。
首先需要明白一件事情,临时变量,在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,即临时变量已经被销毁,临时变量占用的内存空间没有被清空,但是可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了
C 语言里规定:16bit 程序中,返回值保存在 ax寄存器 中,32bit 程序中,返回值保持在 eax寄存器 中,如果是 64bit 返回值,edx寄存器 保存 高32bit,eax寄存器 保存 低32bit
由此可见,函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系
如果我们需要返回值,一般使用赋值语句就可以了。
使用<stddef.h>头文件中的,offsetof宏。
举个例子:
#include<iostream> #include<stddef.h> using namespace std; struct S { int x; char y; int z; double a; }; int main() { cout << offsetof(S,x) << endl; // 0 cout << offsetof(S,y) << endl; // 4 cout << offsetof(S,z) << endl; // 8 cout << offsetof(S,a) << endl; // 16 这里是16的位置,因为 double是8字节,需要找一个8的倍数对齐 return 0; }
当然了,如果加上 #pragma pack(4) 指定4字节对齐方式就可以了。
#include<iostream> #include<stddef.h> using namespace std; #pragma pack(4) struct S { int x; char y; int z; double a; }; int main() { cout << offsetof(S,x) << endl; // 0 cout << offsetof(S,y) << endl; // 4 cout << offsetof(S,z) << endl; // 8 cout << offsetof(S,a) << endl; // 12 }
S 结构体中各个数据成员的内存空间划分如下所示,需要注意内存对齐
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5YhuMQZt-1668393620913)(1-C++基础.assets/202205220035079.png)]
从上面的定义也可以看出,非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。 举个例子:
#include<iostream> using namespace std; class A { public: /*virtual*/ void func() { std::cout << "A::func()\n" << std::endl; } }; class B : public A { public: void func() { std::cout << "B::func()" << std::endl; } }; class C : public A { public: void func() { std::cout << "C::func()" << std::endl; } }; int main() { C* pc = new C(); // pc的静态类型是它声明的类型C*,动态类型也是C* B* pb = new B(); // pb的静态类型和动态类型也都是B* A* pa = pc; // pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C* pa = pb; //pa的动态类型可以更改,现在它的类型是B*,但其静态类型仍然是声明时候的A* C* pnull = NULL; // pnull 的静态类型是它声明的类型C*,没有动态类型 pa->func(); // A::func() pa的静态类型永远是A*,不管其指向的哪个子类,都是直接调用A::func() pc->func(); // C::func() pc的动、静态类型都是C*,因此调用C::func() pnull->func(); // C::func() 不用奇怪为什么空指针也能调用函数,因为这在编译器就确定了,和指针是否为空没有关系 return 0; } /* A::func() C::func() C::func() */
如果将 A 类中的 virtual 注释去掉,则运行结果是:
pa->func(); //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
pc->func(); //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
pnull->func(); //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;
在上面的例子中,
本文代码里都是针对指针的情况来分析的,但是对于引用的情况同样适用。
至此总结一下静态绑定和动态绑定的区别:
建议:
绝对不要重新定义继承而来的非虚( non-virtual )函数(《Effective C++ 第三版》条款36),因为这样导致函数调用由对象声明时的静态类型确定了,而和对象本身脱离了关系,没有多态,也这将给程序留下不可预知的隐患和莫名其妙的 BUG ;另外,在动态绑定也即在 virtual 函数中,要注意默认参数的使用。当缺省参数和 virtual 函数一起使用的时候一定要谨慎,不然出了问题怕是很难排查。 看下面的代码:
#include<iostream> using namespace std; class E { public: virtual void func(int i = 0) { std::cout << "E::func()\t" << i << std::endl; } }; class F : public E { public: virtual void func(int i = 1) { std::cout << "F::func()\t" << i << std::endl; } }; int main() { F* pf = new F(); E* pe = pf; pf->func(); // F::func() 1 正常 pe->func(); // F::func() 0 调用子类的函数,却使用了基类中参数的默认值 return 0; }
可以。
引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。
举个例子:
#include<iostream> using namespace std; class Base { public: virtual void fun() { cout << "base::fun()" << endl; } }; class Son : public Base { public: virtual void fun() { cout << "Son::fun()" << endl; } void func() { cout << "Son::not virtual function" << endl; } }; int main() { Son s; Base& b = s; // 基类类型引用绑定已经存在的Song对象,引用必须初始化 s.fun(); // Son::fun() b.fun(); // Son::fun() return 0; }
需要说明的是虚函数才具有动态绑定,上面代码中,Son 类中还有一个非虚函数 func() ,这在 b 对象中是无法调用的,如果使用基类指针来指向子类也是一样的。
生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;
使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。
操作系统和编译器通过内存分配的位置可以区分两者,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。
指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,如果再进行操作就会很危险。
举个例子:
#include<iostream>
using namespace std;
int main()
{
int *a,*b,c;
a = (int*)0x500;
b = (int*)0x520;
c = b - a;
printf("%d\n",c); // 8
a += 0x020;
c = b - a;
printf("%d\n",c); //-24
return 0;
}
首先变量 a 和 b 都是以 16进制 的形式初始化,将它们转成 10进制 分别是 1280(5*16^2=1280
)和 1312(5*16^2+2*16=1312
), 那么它们的差值为 32 ,也就是说 a 和 b 所指向的地址之间间隔 32 个位,但是考虑到是 int类型 占 4 位,所以 c 的值为 32/4=8
a 自增 16进制 0x20 之后,其实际地址变为 1280 + 2*16*4 = 1408
,(因为一个 int 占 4 位,所以要乘 4 ),这样它们的差值就变成了1312 - 1280 = -96,所以 c 的值就变成了 -96/4 = -24
遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度,建议都转成10进制计算,计算结果除以类型长度取得结果
对两个浮点数判断大小和是否相等不能直接用 == 来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!浮点数与 0 的比较也应该注意。与浮点数的表示方式有关。
① 备份原来的帧指针,调整当前的栈帧指针到栈指针位置;
② 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存;
③ 使用建立好的栈帧,比如读取和写入,一般使用 mov,push 以及 pop 指令等等。
④ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了
⑤ 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。
⑥ 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理。
⑦ 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。
⑧ 弹出返回地址,跳出当前过程,继续执行调用者的代码。
① call指令
② leave指令
③ ret指令
1) 指针参数传递本质上是值传递,它所传递的是一个地址值。
值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。
值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。
2) 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。
被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。
因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。
3) 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。
而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
4) 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。
指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。
符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。
① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
② 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;
派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。
所以必须定义而非声明。
将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。 RTTI 技术,用 dynamic_cast 进行向下类型转换。
继承是 Is a 的关系,比如说 Student 继承 Person ,则说明 Student is a Person 。继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。
继承的缺点有以下几点:
①:父类的内部细节对子类是可见的。
②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
组合的优点:
①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
③:当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。
组合的缺点:
①:容易产生过多的对象。
②:为了能组合多个对象,必须仔细对接口进行定义。
函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。
一个具体函数的名字,如果后面不跟调用符号(即括号),则该名字就是该函数的指针(注意:大部分情况下,可以这么认为,但这种说法并不很严格)。
int (*pf)(const int&, const int&); (1)
上面的 pf 就是一个函数指针,指向所有返回类型为 int ,并带有两个 const int& 参数的函数。注意 *pf 两边的括号是必须的,否则上面的定义就变成了:
int *pf(const int&, const int&); (2)
而这声明了一个函数 pf ,其返回类型为 int* , 带有两个 const int& 参数。
函数与数据项相似,函数也有地址。我们希望在同一个函数中通过使用相同的形参在不同的时间使用产生不同的效果。
一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变元传递给其他函数;
指针名 = 函数名; 指针名 = &函数名
1、 分配内存的顺序是按照声明的顺序。
2、 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
3、 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
添加了#pragma pack(n)后规则就变成了下面这样:
1、 偏移量要是n和当前变量大小中较小值的整数倍
2、 整体大小要是n和最大变量大小中较小值的整数倍
3、 n值必须为1,2,4,8…,为其他值时就按照默认的分配规则
struct foo
{
int a;
int b;
bool operator==(const foo& rhs) // ==操作符重载
{
return (a == rhs.a) && (b == rhs.b);
}
};
1、调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
2、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
3、在被调函数中,被调函数会先保存调用者函数的栈底地址(push ebp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov ebp,esp);
4、在被调函数中,从 ebp 的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;
在 c/c++ 中,为了解决一些频繁调用的小函数大量消耗栈空间(栈内存)的问题,特别的引入了 inline 修饰符,表示为内联函数。
栈空间就是指放置程序的局部数据(也就是函数内数据)的内存空间。
在系统下,栈空间是有限的,假如频繁大量的使用就会造成因栈空间不足而导致程序出错的问题,如,函数的死循环递归调用的最终结果就是导致栈内存空间枯竭。
#include <stdio.h>
//函数定义为inline即:内联函数
inline char* dbtest(int i) {
return (i % 2 > 0) ? "奇" : "偶";
}
int main()
{
int i = 0;
for (i=1; i < 100; i++) {
printf("i:%d 奇偶性:%s /n", i, dbtest(i));
}
}
上面的例子就是标准的内联函数的用法,使用 inline 修饰带来的好处我们表面看不出来,其实,在内部的工作就是在每个for循环的内部任何调用 dbtest(i) 的地方都换成了(i%2>0)?”奇”:”偶”,这样就避免了频繁调用函数对栈内存重复开辟所带来的消耗
。
inline 的使用时有所限制的,inline 只适合函数体内部代码简单的函数使用,不能包含复杂的结构控制语句例如 while、switch ,并且不能内联函数本身不能是直接递归函数(即,自己内部还调用自己的函数)。
inline 函数仅仅是一个对编译器的建议
,所以最后能否真正内联,看编译器的意思
:它如果认为函数不复杂,能在调用点展开,就会真正内联,并不是说声明了内联就会内联,声明内联只是一个建议而已。
因为内联函数要在调用点展开,所以编译器必须随处可见内联函数的定义,要不然就成了非内联函数的调用了。所以,这要求:每个调用了内联函数的文件都出现了该内联函数的定义。
因此,将内联函数的定义放在头文件里实现是合适的,省却为每个文件实现一次的麻烦。
声明跟定义要一致:如果在每个文件里都实现一次该内联函数的话,那么,最好保证每个定义都是一样的,否则,将会引起未定义的行为。如果不是每个文件里的定义都一样,那么,编译器展开的是哪一个,那要看具体的编译器而定。所以,最好将内联函数定义放在头文件中。
定义在类中的成员函数缺省都是内联的
如果在类定义时就在类内给出函数定义,那当然最好。
如果在类中未给出成员函数定义,而又想内联该函数的话,那在类外要加上 inline ,否则就认为不是内联的。
class A
{
public:void Foo(int x, int y) { } // 自动地成为内联函数
}
将成员函数的定义体放在类声明之中虽然能带来书写上的方便,但不是一种良好的编程风格,上例应该改成:
// 头文件
class A
{
public:
void Foo(int x, int y);
}
// 定义文件
inline void A::Foo(int x, int y){}
关键字 inline 必须与函数定义体放在一起才能使函数成为内联,仅将 inline 放在函数声明前面不起任何作用。
如下风格的函数 Foo 不能成为内联函数:
inline void Foo(int x, int y); // inline 仅与函数声明放在一起,不可以!
void Foo(int x, int y){}
void Foo(int x, int y);
inline void Foo(int x, int y) {} // inline 与函数定义体放在一起
因此,inline 是一种“用于实现的关键字”
,而不是一种“用于声明的关键字”
。
一般地,用户可以阅读函数的声明,但是看不到函数的定义。尽管在大多数教科书中内联函数的声明、定义体前面都加了 inline 关键字,但我认为 inline 不应该出现在函数的声明中。这个细节虽然不会影响函数的功能,但是体现了高质量 C++/C 程序设计风格的一个基本原则:声明与定义不可混为一谈,用户没有必要、也不应该知道函数是否需要内联。
虽然说内联函数可以提高执行效率,但是不可以将所有的函数都定义为内联函数。
内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率
。
如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
以下的情况不适合用内联函数:
函数体内出现循环
,那么执行函数体内代码的时间要比函数调用的开销大。类的构造函数和析构函数容易让人误解成使用内联更有效。要当心构造函数和析构函数可能会隐藏一些行为
,如“偷偷地”执行了基类或成员对象的构造函数和析构函数。所以不要随便地将构造函数和析构函数的定义体放在类声明中
。一个好的编译器将会根据函数的定义体,自动地取消不值得的内联(这进一步说明了 inline 不应该出现在函数的声明中)。内联函数并不是一个增强性能的灵丹妙药。只有当函数非常短小的时候它才能得到我们想要的效果;但是,如果函数并不是很短而且在很多地方都被调用的话,那么将会使得可执行体的体积增大。
最令人烦恼的还是当编译器拒绝内联的时候。在老的实现中,结果很不尽人意,虽然在新的实现中有很大的改善,但是仍然还是不那么完善的。一些编译器能够足够的聪明来指出哪些函数可以内联哪些不能,但是大多数编译器就不那么聪明了,因此这就需要我们的经验来判断。
如果内联函数不能增强性能,就避免使用它!
在 C/C++ 中,对函数参数的扫描是从后向前的。
C/C++ 的函数参数是通过压入堆栈的方式来给函数传参数的(堆栈是一种先进后出的数据结构),最先压入的参数最后出来,在计算机的内存中,数据有 2 块,一块是堆,一块是栈(函数参数及局部变量在这里),而栈是从内存的高地址向低地址生长的,控制生长的就是堆栈指针了,最先压入的参数是在最上面,就是说在所有参数的最后面,最后压入的参数在最下面,结构上看起来是第一个,所以最后压入的参数总是能够被函数找到,因为它就在堆栈指针的上方。
printf 的第一个被找到的参数就是那个字符指针,就是被双引号括起来的那一部分,函数通过判断字符串里控制参数的个数来判断参数个数及数据类型,通过这些就可算出数据需要的堆栈指针的偏移量了,下面给出 printf(“%d,%d”,a,b);(其中a、b都是int型的)的汇编代码。
1、模板定义很特殊。由template<…>
处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。在编译器和连接器的某一处,有一机制能去掉指定模板的多重定义。
所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。
2、在分离式编译的环境下,编译器编译某一个 .cpp 文件时并不知道另一个 .cpp 文件的存在,也不会去查找(当遇到未决符号时它会寄希望于连接器)。这种模式在没有模板的情况下运行良好,但遇到模板时就傻眼了,因为模板仅在需要的时候才会实例化出来。
所以,当编译器只看到模板的声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待连接器能够将符号的地址决议出来。
然而当实现该模板的 .cpp 文件中没有用到模板的实例时,编译器懒得去实例化,所以,整个工程的 .obj 中就找不到一行模板实例的二进制代码,于是连接器也黔驴技穷了。
① public:用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被 访问,在类外也是可以被访问的,是类对外提供的可访问接口;
② private:用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态;
③ protected:用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。
① 若继承方式是public,基类成员在派生类中的访问权限保持不变,也就是说,基类中的成员访问权限,在派生类中仍然保持原来的访问权限;
② 若继承方式是private,基类所有成员在派生类中的访问权限都会变为私有(private)权限;
③ 若继承方式是protected,基类的共有成员和保护成员在派生类中的访问权限都会变为保护(protected)权限,私有成员在派生类中的访问权限仍然是私有(private)权限。
cout<< 是一个函数,cout<< 后可以跟不同的类型是因为 cout<< 已存在针对各种类型数据的重载,所以会自动识别数据的类型。
输出过程会首先将输出字符放入缓冲区,然后输出到屏幕。
cout是有缓冲输出:
cout << "abc" << endl;
或
cout << "abc\n"; cout << flush; // 这两个才是一样的
flush 立即强迫缓冲输出。
printf 是行缓冲输出,不是无缓冲输出。
1、 我们只能重载已有的运算符,而无权发明新的运算符;对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;不能改变运算符操作数个数;
2、 两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数;下标运算符、箭头运算符必须是成员运算符;
3、 引入运算符重载,是为了实现类的多态性;
4、 当重载的运算符是成员函数时,this 绑定到左侧运算符对象。成员运算符函数的参数数量比运算符对象的数量少一个;至少含有一个类类型的参数;
5、 从参数的个数推断到底定义的是哪种运算符,当运算符既是一元运算符又是二元运算符(+,-,*,&);
6、 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;
7、 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;
如果是指变量的声明和定义: 从编译原理上来说,声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。而定义就是分配了内存。
如果是指函数的声明和定义: 声明:一般在头文件里,对编译器说:这里我有一个函数叫function() 让编译器知道这个函数的存在。 定义:一般在源文件里,具体就是函数的实现过程 写明函数体。
1、全局变量(外部变量)的说明之前再冠以 static 就构成了静态的全局变量。
全局变量本身就是静态存储方式,静态全局变量当然也是静态存储方式。
这两者在存储方式上并无不同。这两者的区别在于非静态全局变量的作用域是整个源程序,当一个源程序由多个原文件组成时,非静态的全局变量在各个源文件中都是有效的。
而静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
static 全局变量与普通的全局变量的区别是 static 全局变量只初始化一次,防止在其他文件单元被引用。
2、static 函数与普通函数有什么区别? static 函数与普通的函数作用域不同。尽在本文件中。只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。
对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。 static 函数与普通函数最主要区别是 static 函数在内存中只有一份,普通静态函数在每个被调用中维持一份拷贝程序的局部变量存在于(堆栈)中,全局变量存在于(静态区)中,动态申请数据存在于(堆)
1、生命周期
静态成员变量从类被加载开始到类被卸载,一直存在;
普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束;
2、共享方式
静态成员变量是全类共享;普通成员变量是每个对象单独享用的;
3、定义位置
普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区;
4、初始化位置
普通成员变量在类中初始化;静态成员变量在类外初始化;
5、默认实参
可以使用静态成员变量作为默认实参
1、一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”。有时,希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。
2、条件编译命令最常见的形式为:
#ifdef 标识符
程序段1
#else
程序段2
#endif
它的作用是:当标识符已经被定义过(一般是用 #define 命令定义),则对 程序段1 进行编译,否则编译 程序段2 。 其中 #else 部分也可以没有,即:
#ifdef 标识符
程序段1
#endif
3、在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。
在头文件中使用 #define 、#ifndef 、#ifdef 、#endif 能避免头文件重定义。
1、C++ 的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。所谓隐式转换,是指不需要用户干预,编译器私下进行的类型转换行为。很多时候用户可能都不知道进行了哪些转换
2、C++ 面向对象的多态特性,就是通过父类的类型实现对子类的封装。通过隐式转换,你可以直接将一个子类的对象使用父类的类型进行返回。在比如,数值和布尔类型的转换,整数和浮点数的转换等。某些方面来说,隐式转换给 C++ 程序开发者带来了不小的便捷。C++ 是一门强类型语言,类型的检查是非常严格的。
3、 基本数据类型的转换以取值范围的作为转换基础(保证精度不丢失)。隐式转换发生在从小->大的转换中。比如从 char 转换为 int 。从 int->long 。自定义对象子类对象可以隐式的转换为父类对象。
4、 C++ 中提供了 explicit 关键字,在构造函数声明的时候加上 explicit 关键字,能够禁止隐式转换。
5、如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为 explicit 加以制止隐式类型转换,关键字 explicit 只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为 explicit 。
1、C++ 中的异常情况: 语法错误(编译错误):比如变量未定义、括号不匹配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。 运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。为了有效处理程序运行时错误,C++ 中引入异常处理机制来解决此问题。
2、C++ 异常处理机制: 异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。 C++ 异常处理机制由 3 个模块组成:try(检查)、throw(抛出)、catch(捕获) 抛出异常的语句格式为:throw 表达式;如果 try块 中程序段发现了异常则抛出异常。
try { 可能抛出异常的语句;(检查) } catch(类型名[形参名]) // 捕获特定类型的异常 { // 处理1 } catch(类型名[形参名]) // 捕获特定类型的异常 { // 处理2 } catch(...) // 捕获所有类型的异常 { // 处理3 }
x = x + y;
y = x - y;
x = x - y;
x = x ^ y; // 只能针对int、char...
y = x ^ y;
x = x ^ y;
x ^= y ^= x;
1、复制的内容不同。strcpy 只能复制字符串,而 memcpy 可以复制任意内容,例如字符数组、整型、结构体、类等。
2、复制的方法不同。strcpy 不需要指定长度,它遇到被复制字符的串结束符 “\0” 才结束,所以容易溢出。memcpy 则是根据其第 3 个参数决定复制的长度。
3、用途不同。通常在复制字符串时用 strcpy ,而需要复制其他类型数据时则一般用 memcpy
参数的含义是程序在命令行下运行的时候,需要输入 argc 个参数,每个参数是以 char 类型输入的,依次存在数组里面,数组是 argv[],所有的参数在指针
char * 指向的内存中,数组的中元素的个数为 argc 个,第一个参数为程序的名称。
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。
volatile 用在如下的几个地方:
1、Empty(); // 缺省构造函数
2、Empty(const Empty&); // 拷贝构造函数
3、~Empty(); // 析构函数
4、Empty& operator=(const Empty&); // 赋值运算符
1、C++ 标准库可以分为两部分:
标准函数库: 这个库是由通用的、独立的、不属于任何类的函数组成的。函数库继承自 C 语言。
面向对象类库: 这个库是类及其相关函数的集合。
2、输入/输出 I/O、字符串和字符处理、数学、时间、日期和本地化、动态分配、其他、宽字符函数
3、标准的 C++ I/O 类、String 类、数值类、STL 容器类、STL 算法、STL 函数对象、STL 迭代器、STL 分配器、本地化库、异常处理类、杂项支持库
string s = "abc";
const char* c_s = s.c_str();
const char* c_s = "abc";
string s(c_s);
string s = "abc";
char* c;
const int len = s.length();
c = new char[len + 1];
strcpy(c,s.c_str());
char* c = "abc";
string s(c);
const char* cpc = "abc";
char* pc = new char[strlen(cpc) + 1];
strcpy(pc,cpc);
char* pc = "abc";
const char* cpc = pc;
1、使用引用参数的主要原因有两个:
程序员能修改调用函数中的数据对象
通过传递引用而不是整个数据–对象,可以提高程序的运行速度
2、一般的原则: 对于使用引用的值而不做修改的函数:
如果数据对象很小,如内置数据类型或者小型结构,则按照值传递;
如果数据对象是数组,则使用指针(唯一的选择),并且指针声明为指向const的指针;
如果数据对象是较大的结构,则使用const指针或者引用,已提高程序的效率。这样可以节省结构所需的时间和空间;
如果数据对象是类对象,则使用const引用(传递类对象参数的标准方式是按照引用传递);
3、对于修改函数中数据的函数:
如果数据是内置数据类型,则使用指针
如果数据对象是结构,则使用引用或者指针
如果数据是类对象,则使用引用
也有一种说法认为:“如果数据对象是数组,则只能使用指针”,这是不对的,比如
#include<iostream>
using namespace std;
template<typename T, int N>
void func(T(&a)[N])
{
a[0] = 2;
}
int main()
{
int a[] = { 1,2,3 };
func(a);
cout << a[0] << endl;
return 0;
}
1、对象的静态类型:对象在声明时采用的类型。是在编译期确定的。
2、对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。
3、静态绑定:绑定的是对象的静态类型,某特性(比如函数依赖于对象的静态类型,发生在编译期。)
4、动态绑定:绑定的是对象的动态类型,某特性(比如函数依赖于对象的动态类型,发生在运行期。)
1、为类设计一个 static 静态变量 count 作为计数器;
2、类定义结束后初始化 count ;
3、在构造函数中对 count 进行 +1 ;
4、 设计拷贝构造函数,在进行拷贝构造函数中进行 count+1 ,操作;
5、设计赋值构造函数,在进行赋值函数中对 count+1 操作;
6、在析构函数中对 count 进行 -1 ;
1、如果是简单的错误,可以直接双击错误列表里的错误项或者生成输出的错误信息中带行号的地方就可以让编辑窗口定位到错误的位置上。
2、对于复杂的模板错误,最好使用生成输出窗口。
多数情况下出发错误的位置是最靠后的引用位置。如果这样确定不了错误,就需要先把自己写的代码里的引用位置找出来,然后逐个分析了。
1、当初始化一个引用成员变量时;
2、初始化一个const成员变量时;
3、当调用一个基类的构造函数,而构造函数拥有一组参数时;
4、当调用一个成员类的构造函数,而他拥有一组参数;
5、编译器会一一操作初始化列表,以适当顺序在构造函数之内安插初始化操作,并且在任何显示用户代码前。list 中的项目顺序是由类中的成员声明顺序决定的,不是初始化列表中的排列顺序决定的。
对比值传递,引用传参的好处:
1)在函数内部可以对此参数进行修改
2)提高函数调用和运行的效率(因为没有了传值和生成副本的时间和空间消耗)
如果函数的参数实质就是形参,不过这个形参的作用域只是在函数体内部,也就是说实参和形参是两个不同的东西,要想形参代替实参,肯定有一个值的传递。函数调用时,值的传递机制是通过“形参=实参”来对形参赋值达到传值目的,产生了一个实参的副本。即使函数内部有对参数的修改,也只是针对形参,也就是那个副本,实参不会有任何更改。函数一旦结束,形参生命也宣告终结,做出的修改一样没对任何变量产生影响。
用引用作为返回值最大的好处就是在内存中不产生被返回值的副本。
但是有以下的限制:
1)不能返回局部变量的引用。因为函数返回以后局部变量就会被销毁
2)不能返回函数内部 new 分配的内存的引用。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部 new 分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由 new 分配)就无法释放,造成 memory leak (内存溢出)
3)可以返回类成员的引用,但是最好是 const 。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。
① strcpy 的两个操作对象均为字符串
② sprintf 的操作源对象可以是多种数据类型,目的操作对象是字符串
③ memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
memcpy 最高,strcpy 次之,sprintf 的效率最低。
① strcpy 主要实现字符串变量间的拷贝
② sprintf 主要实现其他数据类型格式到字符串的转化
③ memcpy 主要是内存块间的拷贝。
1、传递引用给函数与传递指针的效果是一样的。
这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。
2、使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;
而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;
如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
3、使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;
另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
1、数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);
2、用运算符 sizeof 可以计算出数组的容量(字节数)。sizeof§; p 为指针得到的是一个指针变量的字节数,而不是 p 所指的内存容量。
3、编译器为了简化对数组的支持,实际上是利用指针实现了对数组的支持。具体来说,就是将表达式中的数组元素引用转换为指针加偏移量的引用。
4、在向函数传递参数的时候,如果实参是一个数组,那用于接受的形参为对应的指针。也就是传递过去是数组的首地址而不是整个数组,能够提高效率;
5、在使用下标的时候,两者的用法相同,都是原地址加上下标值,不过数组的原地址就是数组首元素的地址是固定的,指针的原地址就不是固定的。
1、将类定义为抽象基类或者将构造函数声明为private;
2、不允许类外部创建类对象,只能在类内部创建对象
1、为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成 private ,防止被调用。
2、类的成员函数和 friend 函数还是可以调用 private 函数,如果这个 private 函数只声明不定义,则会产生一个连接错误;
3、针对上述两种情况,我们可以定一个 base 类,在 base 类中将拷贝构造函数和拷贝赋值函数设置成 private ,那么派生类中编译器将不会自动生成这两个函数,且由于 base 类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。
1、调试版本,包含调试信息,所以容量比 Release 大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug 模式下生成两个文件,除了.exe 或 .dll 文件外,还有一个 .pdb 文件,该文件记录了代码中断点等调试信息;
2、发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的 PDB 文件中生成)。Release 模式下生成一个文件 .exe 或 .dll 文件。
3、实际上,Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语句的发布版本。
程序运行过程入口点 main 函数,main() 函数返回值类型必须是 int ,这样返回值才能传递给程序激活者(如操作系统)表示程序正常退出。
main(int args, char **argv) 参数的传递。参数的处理,一般会调用 getopt() 函数处理,但实践中,这仅仅是一部分,不会经常用到的技能点。
#include<iostream>
using namespace std;
template<typename type1,typename type2> // 函数模板
type1 Max(type1 a,type2 b)
{
return a > b ? a : b;
}
void main()
{
cout << "Max = " << Max(5.5,'a') << endl;
}
/*
Max = 97
*/
其实该模板有个比较隐晦的 bug ,那就是 a、b 只有在能进行转型的时候才能进行比较,否则 a > b 这一步是会报错的。
这个时候往往需要对于 > 号进行重载,这代码量瞬间上来了。
char* strcpy(char* strDest, const char* strSrc);
char* strncpy(char* dest, const char* src, size_t n);
strcpy 函数: 如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出(buffer Overflow)的错误情况,在编写程序时请特别留意,或者用 strncpy() 来取代。
strncpy 函数:用来复制源字符串的前 n 个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置 n 个字符。
如果目标长>指定长>源长,则将源长全部拷贝到目标长,自动加上’\0’
如果指定长<源长,则将源长中按指定长度拷贝到目标字符串,不包括’\0’
如果指定长>目标长,运行时错误 ;
1、更加安全;
2、更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误;可清楚地辨别代码中每个显式的强制转;可读性更好,能体现程序员的意图
1、有时候类里面定义了很多 int , char , struct 等 C 语言里的那些类型的变量,我习惯在构造函数中将它们初始化为 0 ,但是一句句的写太麻烦,所以直接就 memset(this, 0, sizeof *this); 将整个对象的内存全部置为 0 。对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;
2、类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
3、类中含有 C++ 类型的对象:例如,类中定义了一个 list 的对象,由于在构造函数体的代码执行之前就对 list 对象完成了初始化,假设 list 在它的构造函数里分配了内存,那么我们这么一做就破坏了 list 对象的内存。
为了实现 C++ 的多态,C++ 使用了一种动态绑定的技术。这个技术的核心是虚函数表(下文简称虚表)。本文介绍虚函数表是如何实现动态绑定的。
每个包含了虚函数的类都包含一个虚表。
我们知道,当一个类(A)继承另一个类(B)时,类 A 会继承类 B 的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。
我们来看以下的代码。类 A 包含虚函数 vfunc1,vfunc2 ,由于类 A 包含虚函数,故类 A 拥有一个虚表。
class A {
public:
virtual void vfunc1();
virtual void vfunc2();
void func1();
void func2();
private:
int m_data1, m_data2;
};
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5J5Twfan-1668393620914)(1-C++基础.assets/20210313073257693.jpg)]
虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
虚表内的条目,即虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。
(所以不能使用 memset(this,0,sizeof(*this)) 来将变量全部初始化为 0)
虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tKJ3Bkey-1668393620914)(1-C++基础.assets/20210313073408186.png)]
上面指出,一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。
说到这里,大家一定会好奇 C++ 是如何利用虚表和虚表指针来实现动态绑定的。我们先看下面的代码。
class A { public: virtual void vfunc1(); virtual void vfunc2(); void func1(); void func2(); private: int m_data1, m_data2; }; class B : public A { public: virtual void vfunc1(); void func1(); private: int m_data3; }; class C: public B { public: virtual void vfunc2(); void func2(); private: int m_data1, m_data4; };
类 A 是基类,类 B 继承类 A,类 C 又继承类 B。类 A,类 B,类 C,其对象模型如下图 3 所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-66Wz6EKd-1668393620914)(1-C++基础.assets/20210313073446509.png)]
由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类 A 的虚表(A vtbl),类 B 的虚表(B vtbl),类 C 的虚表(C vtbl)。类 A,类 B,类 C 的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。
类 A 包括两个虚函数,故 A vtbl 包含两个指针,分别指向 A::vfunc1() 和 A::vfunc2() 。
类 B 继承于类 A,故类 B 可以调用类 A 的函数,但由于类 B 重写了 B::vfunc1() 函数,故 B vtbl 的两个指针分别指向 B::vfunc1() 和 A::vfunc2() 。
类 C 继承于类 B,故类 C 可以调用类 B 的函数,但由于类 C 重写了 C::vfunc2() 函数,故 C vtbl 的两个指针分别指向 B::vfunc1()(指向继承的最近的一个类的函数)和 C::vfunc2() 。
虽然 图 3 看起来有点复杂,但是只要抓住“对象的虚表指针用来指向自己所属类的虚表,虚表中的指针会指向其继承的最近的一个类的虚函数”这个特点,便可以快速将这几个类的对象模型在自己的脑海中描绘出来。
非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。
假设我们定义一个类 B 的对象。由于 bObject 是类 B 的一个对象,故 bObject 包含一个虚表指针,指向类 B 的虚表。
int main()
{
B bObject;
}
现在,我们声明一个类 A 的指针p
来指向对象bObject
。虽然p
是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以p
可以访问到对象bObject
的虚表指针。bObject
的虚表指针指向类 B 的虚表,所以p
可以访问到 B vtbl。如图 3 所示。
int main()
{
B bObject;
A *p = & bObject;
}
当我们使用p
来调用vfunc1()
函数时,会发生什么现象?
int main()
{
B bObject;
A *p = & bObject;
p->vfunc1();
}
程序在执行p->vfunc1()
时,会发现 p 是个指针,且调用的函数是虚函数,接下来便会进行以下的步骤。
首先,根据虚表指针p->__vptr
来访问对象 bObject 对应的虚表。虽然指针 p 是基类A*
类型,但是*__vptr
也是基类的一部分,所以可以通过p->__vptr
可以访问到对象对应的虚表。
然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于 p->vfunc1()
的调用,B vtbl 的第一项即是 vfunc1 对应的条目。
最后,根据虚表中找到的函数指针,调用函数。从图 3 可以看到,B vtbl 的第一项指向B::vfunc1()
,所以 p->vfunc1()
实质会调用B::vfunc1()
函数。
如果 p 指向类 A 的对象,情况又是怎么样?
int main()
{
A aObject;
A *p = &aObject;
p->vfunc1();
}
当 aObject 在创建时,它的虚表指针__vptr
已设置为指向 A vtbl,这样p->__vptr
就指向 A vtbl。vfunc1在 A vtbl 对应在条目指向了A::vfunc1()
函数,所以 p->vfunc1()
实质会调用A::vfunc1()
函数。
可以把以上三个调用函数的步骤用以下表达式来表示:
(*(p->__vptr)[n])(p)
可以看到,通过使用这些虚函数表,即使使用的是基类的指针来调用函数,也可以达到正确调用运行中实际对象的虚函数。
我们把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。
那么,什么时候会执行函数的动态绑定?这需要符合以下三个条件。
如果一个函数调用符合以上三个条件,编译器就会把该函数调用编译成动态绑定,其函数的调用过程走的是上述通过虚表的机制。
封装,继承,多态是面向对象设计的三个特征,而多态可以说是面向对象设计的关键。C++ 通过虚函数表,实现了虚函数与对象的动态绑定,从而构建了 C++ 面向对象程序设计的基石。
1、当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数;
2、回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:1,声明;2,定义;3,设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用;
3、回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用为调用它所指向的函数时,我们就说这是回调函数;
4、因为可以把调用者与被调用者分开。调用者不关心谁是被调用者,所有它需知道的,只是存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调用函数。
一致性哈希
一致性哈希是一种哈希算法,就是在移除或者增加一个结点时,能够尽可能小的改变已存在key的映射关系
尽可能少的改变已有的映射关系,一般是沿着顺时针进行操作,回答之前可以先想想,真实情况如何处理
一致性哈希将整个哈希值空间组织成一个虚拟的圆环,假设哈希函数的值空间为0~2^32-1,整个哈希空间环如下左图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FKiJiZ1e-1668393620914)(1-C++基础.assets/202205212342491.png)]
一致性 hash 的基本思想就是使用相同的 hash 算法将数据和结点都映射到图中的环形哈希空间中,上右图显示了 4 个数据 object1-object4 在环上的分布图
结点和数据映射
假如有一批服务器,可以根据IP或者主机名作为关键字进行哈希,根据结果映射到哈希环中,3 台服务器分别是 nodeA-nodeC
现在有一批的数据 object1-object4 需要存在服务器上,则可以使用相同的哈希算法对数据进行哈希,其结果必然也在环上,可以沿着顺时针方向寻找,找到一个结点(服务器)则将数据存在这个结点上,这样数据和结点就产生了一对一的关联,如下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9ey0LPaS-1668393620915)(1-C++基础.assets/202205212343455.png)]
移除结点
如果一台服务器出现问题,如上图中的 nodeB ,则受影响的是其逆时针方向至下一个结点之间的数据,只需将这些数据映射到它顺时针方向的第一个结点上即可,下左图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CvDJRb3w-1668393620915)(1-C++基础.assets/202205212343330.png)]
添加结点
如果新增一台服务器 nodeD ,受影响的是其逆时针方向至下一个结点之间的数据,将这些数据映射到 nodeD 上即可,见上右图
虚拟结点
假设仅有 2 台服务器:nodeA 和 nodeC,nodeA 映射了 1 条数据,nodeC 映射了 3 条,这样数据分布是不平衡的。引入虚拟结点,假设结点复制个数为 2 ,则 nodeA 变成:nodeA1 和 nodeA2 ,nodeC 变成:nodeC1 和 nodeC2 ,映射情况变成如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g0On0ad2-1668393620916)(1-C++基础.assets/202205212343034.png)]
这样数据分布就均衡多了,平衡性有了很大的提高
主要处理源代码文件中的以 “#” 开头的预编译指令。处理规则见下:
把预编译之后生成的 xxx.i 或 xxx.ii 文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器 as 完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样) xxx.o (Linux 下)、xxx.obj (Window下)。
将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链 接:
函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西, 在执行的时候运行速度快。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分副本,而是这多个程序在执行时共享同一份副本;
更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
因为编译器必须能够读取这个结构的声明以理解这个数据类型的大、行为等方面的所有规则。
有一条规则在任何关系中都很重要,那就是谁可以访问我的私有部分。
勘误
本题问题表达有误,实际上:
友元函数不一定要在类内声明,普通的友元函数可以在类外声明,也可以在类内声明。
只有友元工厂必须用到类内声明友元函数。
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
友元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员。但是需要在类的定义中声明所有可以访问它的友元函数。
#include<iostream> using namespace std; class A { public: friend void set_show(int x, A& a); // 该函数是友元函数声明 private: int data; }; void set_show(int x,A& a) // 友元函数定义,为了访问类A中的成员 { a.data = x; cout << a.data << endl; } int main(void) { class A a; set_show(1,a); return 0; } /* 1 */
一个函数可以是多个类的友元函数,但是每个类中都要声明这个函数。
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
但是另一个类里面也要相应的进行声明
#include<iostream> using namespace std; class A { public: friend class C; // 这是友元类的声明 private: int data; }; class C // 友元类定义,为了访问类A中的成员 { public: void set_show(int x,A& a) { a.data = x; cout << a.data << endl; } }; int main(void) { class A a; class C c; c.set_show(1, a); return 0; }
使用友元类时注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明
#include<iostream> using namespace std; // C++ 中的继承和多态 struct A { virtual void fun() // C++中的多态:通过虚函数实现 { cout << "A::fun()" << endl; } int a; }; struct B : public A // C++中的继承:B类公有继承A类 { virtual void fun() // C++中的多态:通过虚函数实现(子类的关键字 virtual) { cout << "B::fun()" << endl; } int b; }; // C语言 模拟 C++ 的继承和多态 typedef void (*FUN)(); // 定义一个函数指针来实现对成员函数的支持 struct _A // 父类 { public: FUN _fun; // 由于C语言中结构体不能包含函数,故只能用函数指针在外面实现 int _a; }; class _B // 子类 { public: _A _a_; // 在子类中定义一个基类的对象即可实现对父类的继承 int _b; }; void _fA() // 父类的同名函数 { printf("_A:_fun()\n"); } void _fB() // 子类同名函数 { printf("_B:_fun()\n"); } void Test() { // 测试 C++ 中的继承与多态 A a; // 定义一个父类对象 a B b; // 定义一个子类对象 b A* p1 = &a; // 定义一个父类指针指向父类的对象 p1->fun(); // 调用子类的同名函数 p1 = &b; // 让父类指针指向子类的对象 p1->fun(); // 调用子类的同名函数 // C语言模拟继承与多态的测试 _A _a; // 定义一个父类对象 _a _B _b; // 定义一个子类对象 _b _a._fun = _fA; // 父类的对象调用父类的同名函数 _b._a_._fun = _fB; // 子类的对象调用子类的同名函数 _A* p2 = &_a; // 定义一个父类指针指向父类的对象 p2->_fun(); // 调用父类的同名函数 p2 = (_A*)&_b; // 让父类指针指向子类的对象,由于类型不匹配所以要进行强转 p2->_fun(); // 调用子类同名函数 } int main() { Test(); return 0; } /* A::fun() B::fun() _A:_fun() _B:_fun() */
1、静态编译,编译器在编译可执行文件时,把需要用到的对应动态链接库中的部分提取出来,连接到可执行文件中去,使可执行文件在运行时不需要依赖于动态链接库;
2、动态编译的可执行文件需要附带一个动态链接库,在执行时,需要调用其对应动态链接库的命令。所以其优点一方面是缩小了执行文件本身的体积,另一方面是加快了编译速度,节省了系统资源。缺点是哪怕是很简单的程序,只用到了链接库的一两条命令,也需要附带一个相对庞大的链接库;二是如果其他计算机上没有安装对应的运行库,则用动态编译的可执行文件就不能运行。
以下是一个 hello.c 程序:
#include<stdio.h>
int main()
{
printf("hello world");
return 0;
}
在 Unix 系统上,由编译器把源文件转换为目标文件。
gcc -o hello hello.c
这个过程大致如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vkQBp3Ya-1668393620916)(1-C++基础.assets/202205212343505.png)]
静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MnvelL4a-1668393620916)(1-C++基础.assets/202205212343130.png)]
静态库有以下两个问题:
共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YY3rHkz7-1668393620917)(1-C++基础.assets/202205212343182.png)]
源代码-->预处理-->编译-->优化-->汇编-->链接–>可执行文件
1、预处理
读取c源程序,对其中的伪指令(以#开头的指令)和特殊符号进行处理。包括宏定义替换、条件编译指令、头文件包含指令、特殊符号。 预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。.i 预处理后的 c 文件,.ii 预处理后的 C++ 文件。
2、编译阶段
编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。.s 文件
3、汇编过程
汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个 C 语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。.o 目标文件
4、链接阶段
链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够诶操作系统装入执行的统一整体。
一次只能一个线程拥有互斥锁,其他线程只有等待
互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁
互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。