C++学习笔记1--类

C++书籍推荐

  1. 语言:C++ Primer, <the C++ programming lauguage>
  2. 经验:effective C++ 系列
  3. 标准库:the C++ standard library STL 源码解析

本文主要介绍C++中的类的基础,通过complex和string两个类介绍类的基础构成和规范写法。主要包括构造函数,拷贝、赋值构造函数,穿插一些小知识点,比如头文件、单例模式、内存管理等知识。

头文件和类的声明

C vs C++

在C中,所有的函数都可以访问data。在C++中,将数据和函数封装在一起,只有这个类中的函数才能访问这个类中的数据。

  • Object Based: 单一class的设计
  • Object Oriented: 多重classes之间的设计。

classes 的两个经典分类:

  • class without pointer members (complex)(多半不需要析构函数)
  • class with pointer members (string)

头文件

为了防止头文件的重复包含,要定义下面的防卫式声明:

#ifndef __COMPLEX__
#define __COMPLEX__
#endif

ifndef和pragma once的区别

ifndef的方式受C/C++语言标准支持。它不光可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件(或者代码片段)不会被不小心同时包含。

当然,缺点就是如果不同头文件中的宏名不小心“撞车”,可能就会导致你看到头文件明明存在,编译器却硬说找不到声明的状况——这种情况有时非常让人抓狂。

由于编译器每次都需要打开头文件才能判定是否有重复定义,因此在编译大型项目时,ifndef会使得编译时间相对较长,因此一些编译器逐渐开始支持#pragma once的方式。

pragma once一般由编译器提供保证:同一个文件不会被包含多次。注意这里所说的“同一个文件”是指物理上的一个文件,而不是指内容相同的两个文件。你无法对一个头文件中的一段代码作pragma once声明,而只能针对文件。

其好处是,你不必再费劲想个宏名了,当然也就不会出现宏名碰撞引发的奇怪问题。大型项目的编译速度也因此提高了一些。

对应的缺点就是如果某个头文件有多份拷贝,本方法不能保证他们不被重复包含。当然,相比宏名碰撞引发的“找不到声明”的问题,这种重复包含很容易被发现并修正。

文件结构

  1. 类的声明(模板)
  2. 类的实现
  3. 前置声明

inline 函数

inline是指嵌入代码,就是在调用函数的地方不是跳转,而是把代码直接写到那里去。对于短小的代码来说,inline可以带来一定的效率提升,而且和C时代的宏函数相比,inline 更安全可靠。

内联函数和宏的区别

  1. 内联函数在运行时可调试,而宏定义不可以;
  2. 编译器会对内联函数的参数类型做安全检查或自动类型转换(同普通函数),而宏定义则不会;
  3. 内联函数可以访问类的成员变量,宏定义则不能;
  4. 在类中声明同时定义的成员函数,自动转化为内联函数。

内联函数的适用情况

  1. 一个函数不断被重复调用。

  2. 函数只有简单的几行,且函数不包含for、while、switch语句。

inline函数对编译器来说只是一个建议,编译器可以选择忽略。

构造函数

class complex
{
    complex (double r=0, double i=0)
    :re(r), im(i)	//1
    {}	//2

private:
    double re, im;
};

在1和2位置初始化的区别:在1是初始化,在2是赋值。

类的初始化尽量使用初始化列表。

构造函数的重载

1. complex (double r=0, double i=0)
    :re(r), im(i)	//1
    {}	
2. complex ():re(0),im(0) {}

1和2不可以同时存在,假如有代码complex c1; complex c2();,两个都不带有参数,构造函数1和2都不带有参数,都可以调用,所以这两个不能同时存在。

单例模式

构造函数通常不会放在private里面,因为在创造对象的时候会调用构造函数,如果构造函数是private,就不能被外界调用。但是下面的单例模式情况例外:

class A {
public:
    static A& getInstance();
    setup () {...}
private:
    A();
    A(const A& rhs);
    static A a;
    ...
};

A& A::getInstance()
{
    
    return a;
}

A::getInstance().setup();

首先private里面有一个自己a,然后构造函数也是私有的,也就是不允许外界构造。类A只有一个对象a。那么外界如何调用呢,通过A::getInstance返回的a,然后调用setup进行其他操作。
但是这种写法,当外界不调用的时候a仍然存在。

class A {
public:
    static A& getInstance();
    setup () {...}
private:
    A();
    A(const A& rhs);

    ...
};

A& A::getInstance()
{
    static A a;
    return a;
}

A::getInstance().setup();

只有有人调用的时候a才会存在,并且只有一份。

常量成员函数

double real() const { return re; }

如果函数不改变数据,在函数后面加上const关键字。

如果函数没有加const关键字,当有一个常量类对象const complex c1(2,1)想调用c1.real()时,用户指定c1不会改变,但是函数却没有加const,意思是可能会改变c1的值,这时就会产生矛盾。具体见下面const一章。

参数传递 value/reference(to const)

参数传递尽量使用引用,返回值传递也尽量使用引用

值传递就是把一整包数据传递过去,通过栈。数据多大就传多大。
引用只是传递指针,速度比较快。
有时候只希望传递效率快,但是不希望传递的值被改变,这时候需要加const。

不可以使用引用的情况

函数的结果传递有两种情况,第一种是生成一个临时变量,比如return a+b;;第二种是返回东西已经有空间存放。
此时第一种情况不可以返回引用,因为当函数结束的时候,这个对象就会消失,传递的引用是坏的。(参考操作符重载-非成员函数的例子)

当使用引用时,传递者无需考虑接受者是以什么形式接收
参考操作符重载一章。

指针和引用的区别参考下面的博客:

https://www.cnblogs.com/x_wukong/p/5712345.html

友元

友元函数打破封装,可以直接拿类里面的数据,效率比较高。

int func(const complex& param)
{ return param.re + param.im; }
c2.func(c1);

相同class 的各个objects互为friend

操作符重载

操作符实际上也是一种函数,C++允许重载。

成员函数

c2+=c1;

+=重载使用成员函数是因为需要调用类的成员变量。
符号+=作用在左边的对象,编译器寻找左边对象中有没有+=这个函数。

inline complex& 	//1
__doapl(complex* ths, const complex& r)
{
    ths->re += r.re;
    ths->im += r.im;
    return *ths;
}
inline complex& 	//2
complex::operator +=(const complex& r)
{
    return __doapl(this,r);
}

所有的成员函数都隐含有一个this指针,在这里把c2传给this指针,c1传给r。__doapl(do assignment plus)封装成一个函数便于其他函数调用。

当使用引用时,传递者无需考虑接受者是以什么形式接收
看上面的代码1,return *ths但是返回参数写的是complex&
传递一个object,接受者是complex&,如果对方也是引用,ok。如果对方用value也可以。

允许连续赋值
在语句2地方返回引用使得用户可以使用c3+=c2+=c1这种用法。假如函数返回的是void,那么c3+=(void)是不可以的。

问题:返回值是complex类型可以连续赋值吗?

非成员函数

inline complex
operator + (const complex& x, const complex& y)
{
    return complex(real(x)+real(y),
                    imag(x)+imag(y));
}

inline complex
operator + (const complex& x, double y){...}

inline complex
operator + (double x, const complex& y){...}

这三种重载函数允许复数+复数、复数+实数、实数+复数。

注意这里的函数不能返回引用,因为他们的返回值一定是个local object。

complex();–typename();的用法就是创建了一个临时对象。在下一行生命结束。

问题:complex(); complex c3();complex c3的区别

问题:这里的double类型为什么不用const呢?

上面函数后面加const是因为怕使用者创建const对象调用非const函数。参数上加const是因为防止被修改,如果不想被修改就加上const。这里不加是因为没有加的必要,从第一个原因上来说,加入调用者创建const double y,也能传给double y。 另外这里很确定不会改变y的值。
const也是函数签名的一部分,也就是说允许加const和不加const的函数同时存在。

<<运算符重载

只能写成非成员函数。如果写成成员函数,那么使用时成了c1<<cout,这是不可以的。

ostream&
operator <<(ostream& os, const complex& x)
{
    return os << '(' << real(x) << ',' << imag(x) << ')';
}

调用:
cout<<c1;
  1. 参数的确定:第一个参数是<<左边的对象,也就是cout的类型。第二个参数是<<右边的对象。
  2. 要不要const?每次输出的时候实际上在改变os的状态,所以不能加const。
  3. 返回值类型。为了能够连续输出,还需要作为左值,所以返回值还是ostream类型。

实现complex类需要注意的问题

  1. +=运算符只重载了一次complex& operator +=( const complex& y),5被转化为complex类。 对应的调用注意加括号。cout<<(c2+=5)<<endl; 如果不实现为友元函数的话,则应该在类内实现,因为需要调用this指针。
  2. <<的重载函数要写成std::ostream,并且包含头文件#include . 另外ostream不允许在数据声明中使用inline。

String类

class String
{
public:
    String(const char* cstr = 0);
    String(const String& str);	//参数是String类本身,所以叫拷贝构造函数
    String& operator= (const String& str);	//同样,拷贝赋值函数
    ~String();
    char* get_c_str() const { return m_data; }	//不改变,加const
private:
    char* m_data;
}

构造函数和析构函数

inline
String:: String(const char* cstr = 0)
{
    if (cstr)
    {
        m_data = new char[strlen(cstr)+1];
        strcpy(m_data, cstr);
    }
    else
    {
        m_data = new char[1];
        *m_data = '\0';
    }
}
inline
String::~String()
{
    delete[] m_data;
}

////////////////////////////////
String s1();
String s2("hello");
    
String* p = new String("hello");
delete p;

问题:new和新建对象方式有什么不同?

拷贝构造和拷贝赋值

带有指针的类必须有拷贝构造函数和拷贝赋值函数。为什么不带指针的类不需要呢?因为系统会默认给一个拷贝构造函数,使两边(二进制)内容一样。对于上面的complex类来说,正是我们需要的。

而对于带有指针的类来说,当使用默认拷贝(浅拷贝)的时候,两个对象指向同一个地方。假如有两个对象,String a(“hello”), String b(“World”). 当浅拷贝的时候,b的指针和a的指针相同,都指向hello的位置。此时world所占的内存就会被泄露。而此时如果a对这个指针指向的地方进行操作,b的值也会被改变。

所以,拷贝构造函数的作用:

  1. 防止内存泄露
  2. 使两个对象的操作互不影响。

这里主要是第二个影响吧,因为拷贝构造的对象还没有生成,所以没有开辟空间。第一个作用主要针对拷贝赋值函数。

为什么拷贝构造函数的参数是引用?
如果允许拷贝构造函数传值,就会在拷贝构造函数中调用拷贝构造函数,就会造成永无休止的递归调用从而导致栈溢出。

深拷贝:

inline
String::String(const String& str)
{
    m_data = new char[ strlen(str.m_data) + 1 ];
    strcpy(m_data , str.m_data);
}

////////////////
String s2(s1);
String s2 = s1;

拷贝构造和拷贝赋值的区别:拷贝构造是以b为蓝本创建一个a,拷贝赋值是使a和b的内容一样。a和b之前都已经被创造出来。

拷贝赋值:

inline
String& String:: operator = (const String& str)
{
    if(this == &str)	//检测自我赋值
    {return *this;}

    delete [] m_data;
    m_data = new char[strlen(str.m_data)+1];
    strcpy(m_data, str.m_data);
    return *this;
}

步骤有3:

  1. 删除自己原来的内容。
  2. 开辟和对方一样的空间。
  3. 将对方的内容拷贝过来。

检测自我赋值的作用:

  1. 提高效率(如果是自我赋值的话,直接返回,不用进行下面的步骤)。
  2. 如果没有这个检测的话,在第一步删除了自身的内容,后面进行第二步的时候就会出错。

考虑上面代码的异常安全性的解法:
上面的代码先delete再new,如果这时内存不够,得到的就是一个空指针,容易引起程序崩溃。
解决的方案有两种:第一种,先new再delete掉原来的空间。这样如果new成功会释放原来的,new不成功能够保留原来的内容。第二种,创建临时实例。如下:

inline
String& String:: operator = (const String& str)
{
    if(this!= &str)
    {
        String strTemp(str);
        char* temp = strTemp.m_data;
        strTemp.m_data = m_data;
        m_data = temp;
    }
    return *this;
}

由于strTemp是一个临时变量,程序运行到if外面之后会自动调用析构函数。strTemp交换之后就是原来的内存空间,所以原来的空间被自动释放。

堆、栈和内存管理

栈,是存在于某作用域(scope)的一块内存空间。包括参数、返回地址、local object等。当离开这个作用域,对象的生命就会消失。

堆,是指由操作系统提供的一块global的内存空间,程序可以动态分配从中获得若干区块。

Complex c3(1,2);
{
    Complex c1(1,2);
    Complex* p = new Complex(3);
    static Complex c2(1,2);
}

c2: 加上static关键字,c2的生命在作用域结束之后依然存在,直到整个程序结束。
问题:c2存放在哪里? 答:静态存储区

c3: global object, 作用域也是整个程序。和static有什么不同?存放在哪里?

static全局变量与普通全局变量有什么区别?

答:static全局变量和普通全局变量存储区域相同,不同的是:

static全局变量只在声明此static全局变量的文件中有效;

普通全局变量对整个源程序都有效,当此源程序包含多于一个文件的程序时,对其他文件依然有效。

new的过程

分为三个步骤:

  1. 分配内存

  2. 转型

  3. 构造函数

     Complex *pc = new Complex(1,2);
     void* mem = operator new (sizeof(Complex));
     pc = static_cast<Complex*>(mem);
     pc-> Complex::Complex(1,2);
    
  4. operator new 内部调用malloc,申请一块空间。

  5. 将mem转成pc

  6. 通过指针调用构造函数,Complex(pc,1,2)。由于构造函数自带this指针,所以这里的this就是pc,而pc就是刚才申请的空间的地址。

delete的过程

    String *ps = new String("Hello");

    ...

    delete ps;

     转化为:
    String::~String(ps);

    operator delete(ps);
  1. 调用析构函数,析构函数中delete[] m_data,hello存放的空间释放掉。
  2. 释放内存,调用free(ps),释放掉指针

类在内存中占用的空间

  1. Complex类,8个字节存放2个double数据,灰色的部分为调试信息。红色的部分是malloc和free为了回收记录的分配空间的大小。因为调用malloc只有一个指针,没办法确定大小。总共加起来空间是52,这个空间必须是16的倍数,所以调整到64.64转换成16进制是40,然后用1bit记录这块空间有没有被分配出去,所以变成了41,也就是红色部分的内容。
  2. 当不需要调试信息的时候,8+4*2,刚好是16

对于array new,一定要使用array delete

对于Complex类来说,比刚才多了一个3.用来记录数组的大小。当使用delete[] p的时候,编译器就知道你要删除的是一个数组,因此会调用3次析构函数来释放空间。如果不加[],编译器只会调用一次,会发生内存泄漏。

调试信息什么时候被回收?是delete p的时候回收吗?
问题:第二步不是删除指针p吗,这里记录的是总的大小,为什么不直接删除所有的大小?

遇到的问题

错误 C4996 ‘strcpy’: This function or variable may be unsafe. Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details. MyString d:\program\c11\mystring\mystring\mysting.h 24

之前的strcpy函数被认为是不安全的,所以提供了strcpy_s代替。如果不想改的话,解决方法:

  1. 其实在下面的输出错误信息中有解决方法,“To disable deprecation , use _CRT_SECURE_NO_WARNINGS”,意思是我们可以不进行兼容性检查,我们可以在项目-属性-配置属性-c/c++-预处理器-预处理定义里边加上一句:_CRT_SECURE_NO_WARNINGS
  2. 也是在下面的输出信息中,我们可以看到有一处错误代号“ error C4996:”,所以我们可以在程序开头加上一句“#pragma warning(disable:4996)”就行,意思是忽略这个错误,
  3. 第三种方法是:我们可以在:项目-属性-配置属性-c/c++中的常规,里面有个SDL选项,关了。还有在代码生成中有个安全检查选项(/GS),关了。虽然这种方法也可以解决这个问题,但是我本人不太提倡这种解决办法,还是前两种解决方法比较好

补充

静态成员static

普通成员变量存在于每个对象当中,当调用成员函数的时候c1.real(),默认传入第一个参数this指针,这样编译器就知道是谁调用了这个函数。

static成员变量指的是只有一份这个值,比如银行账户每个人都有,但是利率只有一个。
静态成员函数用来处理静态成员变量。

静态成员变量在类外定义

静态函数的方式:

  1. 类名调用Account::set_rate(5.0);
  2. 对象调用a.set_rate(7.0);

应用:单例模式

cout

在ostream中对cout做了各种类型的重载,所以cout可以输出很多种类型的数据。

类模板和函数模板

template<typename T>

对数据类型进行操作,每创造不同类型的对象,就会创造出一份代码。所以也有人说模板会引起代码的膨胀。

templete<class T>
inline
const T& min(const T&a, const T&b)
{
    return b < a ? b : a;
}

当对一个类进行比大小的时候,发现对所有的类都是一样的操作,所以把类型变成一个类。
但是在使用的时候不用像类模板一样指明类型,编译器会进行自动推导。
当调用r3=min (r1,r2)的时候,b < a, 但是编译器并不知道怎么比较大小,所以去找b有没有对<的运算符重载。所以还应该在类里对<进行运算符重载。

namespace

  1. using directive: using namespace std;//全部打开

  2. using declaration: using std::cout;

  3. std::cin;

    const

这里const object和const member function相互调用的时候,当const对象调用非const函数时会报错。因为const object指定不会修改成员变量,但是成员函数却说我不保证成员变量不变。
当成员函数的const和non-const版本同时存在,const object只能调用const版本,non-const只能调用non-const。

在string类中有两个对[]的重载函数,const也是函数签名的一部分,所以这两个函数是同时存在的。

charT operator[] (size_type pos) const
{// 不用考虑COW(Copy On Write)}
reference operator[] (size_type pos)
{// 必须考虑COW}

C++学习笔记2中对于委托+复合中有一个例子,如上图。可以用多个指针指向一个string。但是对于这个string的调用会用一个reference counting 进行计数。如果对这个内容进行了改变,那么就要考虑COW.如果是常量字符串,那么就不可能改变内容,所以不必考虑COW。(由于上面的第一条规则,所以const成员函数只能被const对象调用。)

重载operator new/delete/[]

void* operator new(size_t size);
void operator delete(void* p,size_t size);

同样可以定义类成员的重载。
如果想要绕过自定义的重载函数,那么可以用::delete p;

对于new 和delete的重载:

  1. 可以重载operator new这个函数
  2. 可以重载成员或者全局函数
  3. 可以重载placement new(),但是必须有不同的参数列,第一个参数必须是size_t。也可以重载placement operator delete,但是不会被delete调用,只有当ctor出现异常,才会调用。 但是如果delete不一一对应new也没关系,只是自己放弃处理ctor发出的异常。

更多

operator type() const;
explicit complex;
pointer-like object;
template specialization;
standard library;
c++11;