跳转至

Fundamentals⚓︎

2953 个字 155 行代码 预计阅读时间 17 分钟

这里的内容颇有些杂,涵盖了大多数 C++ 的基础知识。

Background⚓︎

在正式进入 C++ 的学习前,不妨先来简单了解一下 C++ 相关的背景知识。

C 语言的优劣
  • 优势:
    • 高效编程
    • 能直接访问机器,适用于 OS ES(end system)
    • 灵活
  • 劣势:
    • 不充分的类型检查
    • 缺乏对大规模编程的支持
    • 面向过程编程 (procedure-oriented programming)
C++ 发展史

C++ 之父——Bjarne Stroustrup,这是他的个人网站

具体内容参见 xyx 老师 C++ 的诞生”

C++ 的第一个标准是 C++98(ISO/IEC 14882:1998),目前最新的标准为 C++23(ISO/IEC 14882:2024)

所以 C++ 是什么呢?

摘自 Wikipedia Outline of C++ 的定义:

C++ is a statically typed, free-form, multi-paradigm, compiled, general-purpose programming language.

其中最鲜明的两个特征是:

    • 类型在编译时确定——C++ 是一种编译型语言(compiled language)

      相对应的概念是解释型语言 (interpreted language),代表语言有 Python 等。编译型语言胜过解释型语言之处在于它能够更加高效地生成机器码(编译器能一次看到所有代码,而解释器一次只能看一条语句;但编译需要一定的时间。

    静态类型(statically typed):

    • C++ 有一个强大的类型系统(type system)
  • 多范式(multi-paradigm):包括以下范式:

C++ 的设计哲学:

  • 可读性(readability):直接用代码表达思想和意图
  • 安全性(safety):尽可能在编译时强制保证安全性
  • 高效性(efficiency):不浪费时间和空间
  • 抽象性(abstraction):将杂乱的构造分好类
  • 多范式(multi-paradigm):赋予程序员完全的控制权、责任和选择权

实际上,C++ 还有助于我们培养良好的编码习惯——在用 C++ 写代码时,我们会很自然地考虑到以下问题:

  • 我是否在以对象本应被使用的方式使用它们?——类型检查、类型安全
  • 我是否正在高效使用内存?——引用 / 拷贝语义、移动语义
  • 我是否在修改我不应该修改的东西?——const const 正确性

而其他语言则放宽了上述限制。

The First C++ Program⚓︎

#include <iostream>
using namespace std;

int main() {
    int number;
    cout << "Enter a decimal number: ";
    cin >> number;
    cout << "The number you entered is " << number << endl;
    return 0;
}

解释

  • C++ 的头文件不用 .h 后缀,且头文件的名称由小写字母构成
  • cincout 分别表示标准输入流标准输出流
    • 它们都支持连续输入 / 输出多个值
    • 注意:cin 无法读取空白字符(包括空格、换行、制表符等)
  • 方便起见,源程序开头加一句 using namespace std,表示使用标准名称空间。如果没有这句的话,后面的 cincoutendl 都要加上 std:: 前缀,否则无法编译
  • <<>> 分别表示插入符(insertor) 提取符(extractor),并分别适用于 coutcin
  • endl:换行,意为 end of line

C++ 中,任何类型的变量存放的都是一个对象(object)。

Interesting Things

以下三段代码是等价的 C++ 代码:

#include <iostream>
#include <string>
int main() {
    auto str = std::make_unique<std::string>("Hello World!");
    std::cout << *str << std::endl;
    return 0;
}
// Prints "Hello World!"
#include "stdio.h"
#include "stdlib.h"
int main(int argc, char **argv) {
    printf("%s", "Hello, world!\n");
    // ^a C function!
    return EXIT_SUCCESS;
}

注:这段代码只能在 x86 架构的 CPU 上被编译。

#include "stdio.h"
#include "stdlib.h"
int main(int argc, char **argv) {
    asm(".LC0:\n\t"
    ".string \"Hello, world!\"\n\t"
    "main:\n\t"
    "push rbp\n\t"
    "mov rbp, rsp\n\t"
    "sub rsp, 16\n\t"
    "mov DWORD PTR [rbp-4], edi\n\t"
    "mov QWORD PTR [rbp-16], rsi\n\t"
    "mov edi, OFFSET FLAT:.LC0\n\t"
    "call puts\n\t");
    return EXIT_SUCCESS;
}

可以看到,C++ 既能向后兼容 C,也能执行汇编代码。Intersting!

Type System⚓︎

  • 我们可以把类型(types) 看作变量的“种类 (category)”。
  • C++ 提供了诸如int , double , string , bool , size_t等多种内建类型 (built-in types)
  • C++ 的一个鲜明特征是它是一种静态类型(statically typed) 语言:每个变量都有一个类型,且一旦声明类型后,之后就不得再修改类型了(而动态类型语言允许这种操作
  • 这种语言的优点是
    • 更加高效:
      • 为编译器提供额外的变量信息,这样它能为变量更加高效地分配内存
      • 根据内存中值的特殊结构,编译器还可能对性能进行优化,消除了运行时检查类型的需求
    • 易于理解
    • 提供了更方便的错误检查

Modern Typing⚓︎

Type Alias with using⚓︎

有时,代码中可能会出现像这样冗长的类型:

std::pair<bool, std::pair<double, double>> solveQuadratic(double a, double b, double c);

可以看到,这个函数的返回值类型特别“长”,不仅对敲代码的程序员而言不方便,也不方便其他人阅读代码。这时,我们可以使用 using 关键字,为类型创建别名:

using Zeros = std::pair<double, double>;
using Solution = std::pair<bool, Zeros>;
Solution solveQuadratic(double a, double b, double c);

现在的函数签名就清爽了很多——不仅类型名缩短了,还能提高可读性!

Type Deduction with auto⚓︎

对于上述问题,还有一种“偷懒”的方法是使用 auto 关键字——我们不指明类型,让编译器自行推断具体的类型,这样我们写代码时就无需操心类型的问题了:

auto result = solveQuadratic(a, b, c);
// This is exactly the same as the above! 
// result still has type std::pair<bool, std::pair<double, double>>
// We just told the compiler to figure this out for us!

很显然,result 的返回结果类型就是 Solution,所以编译器在编译时会自动帮我们推断出这一类型,在编译成机器码前将类型替换为具体的类型。但如果存在歧义的话,编译就有可能会失败,比如:

auto oops() {
  return { 106.0, 107.0 };
}

此时函数的返回值既可能是 std::pair<double, double> 类型,也可能是 std::vector<double> 类型,也就是说存在歧义。此时 auto 就没用了,还得要我们明确指出返回值的类型。

虽然这种方法相当省事,但是也降低了代码的可读性,所以在代码编写时我们需要权衡好其中的利弊。

Structs⚓︎

结构体(struct) 本质上就是将多个具名变量绑定在一起,构成一个新类型。

struct zjuIDCard {
    string name;            
    string type;        
    int idNumber;
};

结构体初始化的两种方式:

zjuIDCard myCard;
myCard.name = "NoughtQ";
myCard.type = "Student";                    
myCard.idNumber = 3230100000;
zjuIDCard myCard = {"NoughtQ", "Student", 3230100000};

std::pair⚓︎

对于只有两个字段的结构体,我们可以用 C++ 自带的 std::pair 类型来替代,使用起来更加方便!

struct Order {
    std::string item;
    int quantity;
};
Order dozen = { "Eggs", 12 };
std::pair<std::string, int> dozen { "Eggs", 12 };
std::string item = dozen.first;                             // "Eggs"
int quantity = dozen.second;                                // 12
  • 声明 std::pair 对象时需要指明每个字段的类型;它支持列表初始化
  • 我们用 .first.second 分别获取 std::pair 对象的第一个和第二个字段

严格地说,std::pair 不是一种类型,而是一种模板(template)(这一概念在之后会详细讲解

template <typename T1, typename T2>
struct pair {
    T1 first;
    T2 second;
};
std::pair<std::string, int>

不要忘记在使用 std::pair 前在程序开头加一句 #include <utility>

Namespace⚓︎

std⚓︎

std 是我们最常用的命名空间,它为我们提供了 C++ 标准库的东西,包括一些内建类型、函数等等。

  • 除去一些基本类型外,在使用内建类型前,需要用 #include 导入相关的头文件,比如:
    • #include <string> -> std::string
    • #include <utility> -> std::pair
    • #include <iostream> -> std::cout, std::endl
  • std 里会存在这样的内容:

    namespace std {
        template 
        <typename T1, typename T2>
        struct pair {
            T1 first;
            T2 second;
        };
        // Other utility code...
    }
    
  • 可以看到,在使用这些内建类型的时候,我们必须加上前缀 std::

  • 如果在程序开头使用语句 using namespace std; 的话,我们就没有加前缀的必要了。但这样做被认为是一种不良的程序设计,因为它会带来歧义,尤其是在有多个命名空间的情况。
  • 更多内容:cppreference

Initialization⚓︎

初始化(initialization):在构造对象时为对象提供初始值的过程。

C++ 提供了以下几种初始化方式:

  • 直接初始化(direct initialization)
  • 统一初始化(uniform initialization)
  • 结构化绑定(structured binding)

Direct Initialization⚓︎

#include <iostream>
int main() {
    int numOne = 12;
    int numTwo(12);
    std::cout << "numOne is: " << numOne << std::endl;
    std::cout << "numTwo is: " << numTwo << std::endl;
    return 0;
}

高亮的两行就是直接初始化的两种方式:

  • 使用赋值号 =:和 C 语言一样
  • 使用圆括号 ():看起来像函数调用,类似创建自定义类对象的构造函数(但实际上内建类型并没有构造函数)

这种初始化方法有一种特性,叫做缩窄转换(narrowing conversion)——C++ 不会进行类型检查,而是尝试将初始化值隐式转换为指定的类型(比如:int num(42.5); -> num == 42,如果能成功转换的话就不会报错。

Uniform Initialization⚓︎

统一初始化是 C++11 标准引入的特性。

#include <iostream>
int main() {
    // Notice the brackets
    int numOne{12};
    std::cout << "numOne is: " << numOne << std::endl;
    return 0;
}

可以看到,统一初始化需要用到花括号 {},此时 C++ 会进行类型检查且不支持类型转换。所以像 int num(42.5); 这句话就无法通过编译,报错信息类似:

test.cpp:5:13: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
    5 |     int num{42.0};
      |             ^~~~
test.cpp:5:13: note: insert an explicit cast to silence this issue
    5 |     int num{42.0};
      |             ^~~~
      |             static_cast<int>( )

所以,统一初始化有以下优点:

  • 安全(safe):不允许缩窄转换,从而避免意外行为或致命系统错误的发生
  • 泛用(ubiquitous):作用于所有类型,包括 vectormap,以及自定义类等
例子
std::map<std::string, int> ages{
    {"Alice", 25},
    {"Bob", 30},
    {"Charlie", 35}
};
std::vector<int> numbers{1, 2, 3, 4, 5};

Structured Binding⚓︎

结构化绑定是 C++17 引入的新特性:

  • 它提供了一种在编译时从具有固定大小的数据结构中初始化一些变量的有用方法
  • 具备同时访问函数返回的多个值的能力
  • 能作用在编译时大小已知的对象上

结构化绑定的语法为:

auto [var1, var2, ..., varN] = expression;
例子
std::tuple<std::string, std::string, std::string> getClassInfo() {
    std::string className = "CS106L";
    std::string buildingName = "Thornton 110";
    std::string language = "C++";
    return {className, buildingName, language};
}

int main() {
    auto [className, buildingName, language] = getClassInfo();
    std::cout << "Come to " << buildingName << " and join us for " << className
            << " to learn " << language << "!" << std::endl;
    return 0;
}

高亮所示语句用到了结构化绑定的特性,可以看到三个在方括号内的变量可以依次接收函数的返回值(包含三个 string 的元组

结构化绑定和 Python 拆包(unpacking) 十分相似。

Strings⚓︎

  • C++ 中,字符串有专门的类,叫作 string
    • C++ 中,强烈建议使用 string 类表示字符串,因为它是真正的字符串类型。而在 C 语言中实际上没有字符串类型,只是用字符数组和字符指针来模拟字符串,而且后者不太安全
    • C++ 字符串末尾没有 \0 字符。事实上,除了 C 语言外,其他语言都是将字符串本身及其长度存在内存中,因此不用 \0 标记结尾
  • 使用 string 类时,必须在代码开头加上 #include <string>
  • 定义字符串变量:string str;
    • 这样声明后,字符串 str 已经有确定的值
  • 使用字符串字面量初始化的三种方式:

    string str = "Hello";
    string str("Hello");
    string str{"Hello"};
    

    其中前两种方式是等价的,且这两种方式适用于其他类型(比如 int 等)

  • 赋值:

    char char1[20];
    char char2[20] = "jaguar";
    string str1;
    string str2;
    char1 = char2;                // illegal
    str1 = str2;                  // legal
    
  • 输入和输出

    • 可以直接用cin / cout读写

      cin >> str;
      cout << str;
      
    • 读取一整行字符串:getline(cin, line_var)

    注意

    如果 cin 之后用到 getline,由于 cin 忽略空白字符,输入流里可能还有未被读取的换行符,而 getline 将会读取一行字符串,直到遇到换行符。所以在使用 getline 前,应当先用 cin.get() 读取换行符(这个函数的功能是读取单个字符,然后再用 getline

  • 获取字符串的单个字符:可以像字符数组一样访问字符串

    string s = "Hello";
    s[0] = 'J';
    
  • 字符串拼接 (concatenation)

    string str3;
    str3 = str1 + str2;
    str1 += str2;
    str1 += "lalala";
    
  • 获取长度:s.length();

    • C 中,. 运算符用于检索结构体内的成员
    • 而在 C++ 中,它又是作为一个检索对象成员的运算符
    小技巧

    如果 vscode 中下载了 C/C++ 插件,那么编写代码时在对象后敲个 . 后,vscode 就会显示该对象可用的所有成员。

  • 创建字符串(使用构造函数)

    string(const char *cp, int len);
    string(const string& s2, int pos);
    string(const string& s2, int pos, int len);
    
  • 获取子字符串:substr(int pos, int len);

  • 改变字符串:

    // const 表示不可修改的变量
    insert(size_t pos, const string& s);
    // 从字符串中删除从 pos 位置开始,长度为 len 的子字符串
    erase(size_t pos = 0, size_t len = npos);
    append(const string& str);
    // 用字符串 str 替代原字符串中从 pos 位置,长为 len 的子字符串
    replace(size_t pos, size_t len, const string& str);  
    
  • 寻找字符串

    • 该函数会返回找到的指定字符串首字符在原字符串中的索引,如果未找到,则返回 -1
    size_t find(const string& str, size_t pos = 0) const;
    

从子字符串开始的所有函数(更确切的说法是“方法”)都是字符串对象的成员,因此实际使用时要用 . 运算符访问:

string s = "NoughtQ666";
string subs = s.substr(6, 3);

更多内容:std::basic_string

Pointers⚓︎

  • 指向对象的指针

    string s = "hello";
    string* ps = &s;
    
  • 指针运算符

    • &:获取地址(ps = &s;
    • *:获取对象((*ps).length()
    • ->:调用函数(ps->length()
  • 对象和指针在声明时的区别

    • string s;:此时对象 s 被创建并被初始化
      • 但是像 int i; 这样的声明的变量不会被初始化
    • string *ps;:此时对象 ps 还不清楚指向何处
  • 赋值

    string s1, s2;
    s1 = s2;
    string *ps1, *ps2;
    ps1 = ps2;
    

更多内容:pointer

评论区

如果大家有什么问题或想法,欢迎在下方留言~