C++概览基础知识

在定义一个变量时,如果变量的类型可以由初始化器推断得到,则无须显式指定其类型:

1
2
3
4
5
auto b = true;     //bool
auto ch = 'x';     //char
auto i = 123;      //int
auto d = 1.2;      //double
auto z = sqrt(y);  //sqrt(y)返回的类型

可以使用=的初始化与auto配合,因为在此过程中不存在可能引发错误的类型转换。
当没有明显的理由需要显式指定数据类型时,一般使用auto。

  • 该定义位于一个较大的作用域中,我们希望代码的读者清楚的知道其类型;
  • 我们希望明确规定某个变量的范围和精度(如希望使用double而非float)。

通过使用auto可以帮助避免冗余,并无须书写长类型名。

  • const:大致意思是“我承诺不改变这个值”。主要用于说明接口,这样在把变量传入函数时就不必担心变量会在函数内被改变。编译器负责确认并执行const的承诺。
  • constexpr:大致意思是”在编译时求值“。主要英语说明常量,作用是允许将数据至于只读内存中以及提升性能。
1
2
3
4
5
6
7
8
9
const int dmv = 17;                   //常量
int var = 17;                         //不是常量
const double max1 = 1.4*square(dmv);  //如果square(17)是常量则正确
const double max2 = 1.4*square(var);  //错误,var不是常量
const double max3 = 1.4*square(var);  //OK,可在运行时求值
double sum(const vector<double>&);    //sum不会更改参数值
vector<double> v{1.2, 3.4, 4.5};      //v不是常量
const doublr s1 = sum(v);             //OK,在运行时求值
constexpr double s2 = sum(v);         //错误,sum(v)不是常量表达式

如果某个函数用在常量表达式中,则该表达式在编译时求值,则函数必须定义成constexpr。

1
2
3
constexpr double square(double x){
    return x*x;
}

要想定义成constexpr,函数必须非常简单:函数中只能有一条用于计算某个值的return语句。constexpr函数可以接受非常量实参,但其结果将不会是一个常量表达式。当程序的上下文不需要常量表达式时,我们可以使用非常量表达式实参来调用constexpr函数,这样就不用把同一个函数定义两次:其中一个用于常量表达式,另一个用于变量。

指针的声明如下:

1
char* p;  //该指针指向字符

*表示”指向….“。指针变量中存放着一个相应类型对象的地址:

1
2
char* p = &v[3]; //p指向v的第四个元素
char x = *p;     //*p是p所指的对象

在表达式中,前置一元运算符*表示”…的内容“,而前置一元运算符&表示”…的地址“。

1
2
3
4
5
6
void print(){
    int v[] = {0,1,2,3,4};

    for(auto x : v)
        cout << x << '\n';
}

如果我们不希望把v的值拷贝到变量x中,而只想令x指向一个元素,则可以书写如下代码:

1
2
3
4
5
6
void increment(){
    int v[] = {0,1,2,3,4};

    for (auto& x : v)
        ++x;
}

一元后值运算符&表示”…的引用“,引用类似于指针,唯一的区别就是无须使用前置运算符*访问所引用的值。同样,一个引用在初始化之后就不能再引用其他对象。当用于声明语句时,运算符(如&、*和[])称为声明运算符。

1
2
3
4
T a[n];  //n个T组成的数组
T* p;    //指向T的指针
T& r;    //T的引用
T f(A);  //一个函数,接受A类型的实参,返回T类型的结果

当没有对象可指或我们希望有一种”没有可用对象“(如在列表的末尾),令指针取值为nullptr(“空指针”),所有指针类型共享同一个nullptr:

1
2
3
double* pd = nullptr;
Link<Record>* lst = nullptr; //指向一个Record的Link指针
int x = nullptr;             //错误

通常情况下,当我们希望指针实参指向某个东西时,最好检查以下是否确实如此。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int count_x(char* p, char x)
//统计p[]中x出现的次数
//假定p指向一个字符数组,该数组的结尾处是0;或者p不指向任何东西
{
    if(p==nullptr) return 0;
    int count = 0;
    for(;*p != 0; ++p)
        if(*p == x)
            ++count;
    return count;
}

有两点需要注意:一是可以使用++将指针移动到数组的下一元素;二是在for语句中如果不需要初始化操作,则可以省略它。
count_x()的定义假定char*是一个c风格字符串,也就是说指针指向了一个字符数组,该数组的结尾处是0。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct Vector{
    int sz;       //元素数量
    double* elem; //指向元素的指针
};

void vector_int(Vector& v, int s)
{
    v.elem = new double[s]; //分配一个数组,包含s个double值
    v.sz = s;
}

v的elem成员被赋予了一个由new运算符生成的指针而sz成员的值则是元素的个数,Vextor&中的符号&指定通过非常量引用的方式传递v,这样vector_int()就能修改传入其中的向量了。
new运算符是从一块名为自由存储(free store)的区域中分配内存。 Vector的一个简单应用如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
double read_and_sum(int s)
{
    Vector v;
    vector_int(v,s);
    for(int i = 0; i != s; ++1)
        cin>>v.elem[i]
    
    double sum = 0;
    for(int i= 0; i != s; ++i)
        sum += v.elem[i];
    return sum;
}

访问struct成员的方式有两种:一种是通过名字或引用,这时我们使用.(点运算符);另一种是通过指针,这时用到的是->。

1
2
3
4
5
6
void f(Vector v, Vector& rv, Vector* pv)
{
    int i1 = v.sz;  //通过名字访问
    int i2 = rv.sz; //通过引用访问
    int i4 = pv->sz;//通过指针访问
}

类含有一系列成员,可能是数据、函数或者类型。类的public成员定义该类的接口,private成员则只能通过接口访问。

1
2
3
4
5
6
7
8
9
class Vector{
public:
    Vector(int s): elem{new double[s]}, sz{s}{}  //构建一个vector
    double& operator[](int i){return elem[i]}  //通过下标访问元素
    int size(){return sz;}
private:
    double* elem; //指向元素的指针
    int sz;       //元素的数量
};

我们仅能通过Vector的接口访问其表示形式,上面的read_and_sum()可简化为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
double read_and_sum(int s)
{
    Vector v(s);
    for(int i = 0; i != v.size(); ++1)
        cin>>v[i]
    
    double sum = 0;
    for(int i= 0; i != v.size(); ++i)
        sum += v[i];
    return sum;
}

与所属类同名的函数称为构造函数,即它是用来构造类的对象的函数。与普通函数不同,构造函数的作用是初始化类的对象,因此定义一个构造函数可以解决类变量为初始化的问题。该构造函数使用成员初始化器列表来初始化vector的成员:

1
:elem{new double[s]}, sz{s}

这条语句的含义是:首先从自由空间获取s个double类型的元素,并用一个指向这些元素的指针初始阿护elem;然后用s初始化sz。
访问元素的功能是由一个下标函数提供的,这个函数名为operator[],它的返回值是对相应元素的引用(double&)。

1
2
3
4
5
enum class Color{red, blue, green};
enum class Traffic_light{green, yellow, red};

Color col = Color::red;
Traffic_light light = Traffic_light::red;

枚举值位于enum class的作用域之内,因此可以在不同的enum class中重复使用枚举值而不引起混淆。
enum后面的class指明枚举是强类型的,且它的枚举值位于制定作用域中。不同的enum class是不同的类型,这有助于防止对常量的意外误用。在上面的例子中,不能混用Traffic_light和Color的值。

1
2
3
Color x = red;                //错误,哪个red
Color y = Traffic_light::red; //错误,此red不是Color的对象
Color z = Color::red;         //OK

同样,也不能隐式的混用Color和整数值:

1
2
int i = Color::red; //错误:Color::red不是一个int
Color c = 2;        //错误:2不是一个Color对象

如果不想显式的限定枚举名字,并且希望枚举值可以是int(无须显式转换),则应该去掉enum class中的class而得到一个“普通的”enum。
默认情况下,enum class只定义了赋值、初始化和比较操作。然而,既然枚举类型是一种用户自定义类型,那么我们就可以为它定义别的运算符:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Traffic_light& operator++(Traffic_light& t)
//前置递增运算符++
{
    switch(t){
        case Traffic_light::green:  return t = Traffic_light::yellow;
        case Traffic_light::yelow:  return t = Traffic_light::red;
        case Traffic_light::red:  return t = Traffic_light::green;
    }
}

Traffic_light next = ++light; //next变为Traffic_light::green

构建C++程序的关键就是清晰的定义这些组成部分之间的交互关系。第一步也是最重要的一步,是将某个部分的接口和实现分离开来。在语言层面,C++使用声明来描述接口。声明指定了使用某个函数或某种类型所需的所有内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
double sqrt(double);

class Vector{
public:
    Vector(int s);
    double& operator[](itn i);
    int size();
private:
    double* elem; //elem指向一个数组,该数组包含sz个double
    int sz;
};

Vector试图访问某个越界的元素时,应该做什么?

  • Vector的作者不知道使用者面临这种情况时希望如何处理。
  • Vector的使用者不能保证每次都检测到问题。
    最佳的解决方案是由Vector的实现者负责检测可能的越界行为,然后通知使用者。之后Vector的使用者可以采取适当的应对措施。例如,Vector::operator能够检测到潜在的越界访问错误并抛出一个out_of_range异常:
1
2
3
4
double& Vector::operator[](int i)
{
    if(i < 0 || size()<=i) throw out_of_range{"Vector::operator[]"};
}

throw负责吧程序的控制权从某个直接或间接调用Vector::operator[]的函数转移到out_of_range异常处理代码。为完成这一目标,实现部分需要展开函数调用栈以便返回主调函数的上下文。如:

1
2
3
4
5
6
7
8
9
void f(Vector& v)
{
    try{//此处异常被后面定义的处理模块处理
        v[v.size()] = 7; //试图访问v末尾之后的位置
    }
    catch(out_of_range){
        //此处处理越界错误
    }
}

将可能处理异常的程序放在一个try块当中。显然,对v[v.size()]的赋值操作将出错。因此,程序进入到提供了out_of_range错误处理代码的catch从句中。

使用异常机制通报越界访问错误是函数检查实参的一个示例,此时,因为基本假设,即所谓的前置条件没有得到满足,所以函数拒绝执行。在正式说明Vector的下标运算符时,我们应该规定类似于”索引值必须在[0:size())范围内“的规则,这一规则是在operator内被检查的。
对于类来说,一条假定某事为真的声明称为类的不变式,简称不变式。建立类的不变式是构造函数的任务,它的另一个作用是确保当成员函数推出时不变式仍然成立。考虑如下情况:

1
Vector v(-27);

与原来版本相比,下面的定义更好:

1
2
3
4
5
Vector::Vector(int s){
    if(s<0) throw length_error{};
    elem = new double[s];
    sz = s;
}

如果new运算符找不到可分配的内存,就会抛出std::bad::alloc。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void test(){
    try{
    Vector v(-27);
    }
    catch(std::length_error){
        // 处理负值问题
    }
    catch(std::bad::alloc){
        // 处理内存耗尽问题
    }
}

可以自定义异常类,然后让它们把指定信息从检测到异常的点传递到处理异常的点。
通过情况下,当遭遇异常后就无法继续完成工作。此时,”处理“异常的含义仅仅是做一些简单地局部资源清理,然后重新抛出异常。
不变式的概念是设计类的关键,而前置条件也在设计函数的过程中起到同样地作用。不变式

  • 帮助我们准确的理解想要什么;
  • 强制要求具体而明确的描述设计,而这有助于确保代码正确。

程序异常负责报告运行时发生的错误。如果能在编译时发现错误,效果会更好。我们可以对其他一些编译时可知的属性做一些简单检查,并以编译器错误消息的形式报告所发现的问题。

1
static_assert(4 <= sizeof(int), "integers are too small"); //检查整数的尺寸

如果4<=sizeof(int)不满足,将会输出integers are too small的信息。也就是说,如果在当前系统上一个int占有的空间不足4个字节,就会报错。我们把这种表达某种期望的语句称为断言。
static_assert机制能用于任何可以表达为常量表达式的东西。例如:

1
2
3
4
5
6
7
8
9
constexpr double C = 2299.456;

void f(double speed)
{
    const double local_max = 160.0/(60*60);

    static_assert(speed < C, "can't go that fast"); //错误,速度必须是个常量
    static_assert(local_max < C, "can't go that fast"); //OK
}

通常情况下,static_assert(A,S)的作用是当A不为true时,把S作为一条编译器错误信息输出。
static_assert最重要的用途是为泛型编程中作为形参的类型设置断言。