C++学习 day4

发布于 27 天前  24 次阅读


Day 4:explicit、析构函数、动态内存和常用类

今天目标:继续学习类与对象后面的内容,理解 explicit、析构函数、new/deletenullptr,并通过链表、string、目录读取代码练习对象封装。

对应代码:

2-代码/day4-/18-explicit示例.cpp
2-代码/day4-/19-析构函数示例.cpp
2-代码/day4-/20-动态内存示例.cpp
2-代码/day4-/test.cpp
2-代码/day4-/list.hpp
2-代码/day4-/list.cpp
2-代码/day4-/21-test-list.cpp
2-代码/day4-/22-string使用示例.cpp
2-代码/day4-/dir.hpp
2-代码/day4-/dir.cpp
2-代码/day4-/23-test-dir.cpp

1、explicit 关键字

explicit 通常用来修饰构造函数,作用是:禁止编译器通过这个构造函数进行隐式类型转换

先看没有 explicit 的情况:

class Demo
{
public:
    Demo(int x)
    {
        _x = x;
    }

private:
    int _x;
};

int main()
{
    Demo d1(100);  // 正常:直接调用构造函数
    Demo d2 = 200; // 也能通过:编译器会自动转换成 Demo(200)

    return 0;
}

这里的:

Demo d2 = 200;

表面上看是把 int 赋给 Demo,实际上编译器会偷偷帮你变成:

Demo d2 = Demo(200);

这就叫隐式类型转换。

如果构造函数前面加上 explicit

class Demo
{
public:
    explicit Demo(int x)
    {
        _x = x;
    }

private:
    int _x;
};

int main()
{
    Demo d1(100);  // 正确:明确调用构造函数
    Demo d2{200};  // 正确:明确调用构造函数

    // Demo d3 = 300; // 错误:explicit 禁止隐式转换

    return 0;
}

一句话记忆:

explicit 用来防止“看起来不像创建对象,但实际被编译器偷偷转成对象”的写法。

常见建议:

如果一个构造函数只接收一个参数,通常建议加 explicit,避免意外转换。

2、析构函数

析构函数也是类中的特殊成员函数,作用是:对象销毁时自动调用,一般用来释放资源

基本特点:

1. 函数名是 ~类名
2. 没有返回值
3. 不能写参数
4. 一个类只能有一个析构函数
5. 对象生命周期结束时自动调用

示例:

#include <iostream>
using namespace std;

class Demo
{
public:
    Demo()
    {
        cout << "Demo()" << endl;
    }

    ~Demo()
    {
        cout << "~Demo()" << endl;
    }
};

int main()
{
    Demo d1;
    cout << "-------" << endl;
    return 0;
}

执行过程:

1. 创建 d1,自动调用构造函数 Demo()
2. main 函数结束,d1 销毁,自动调用析构函数 ~Demo()

构造函数和析构函数可以这样理解:

构造函数:对象出生时自动调用
析构函数:对象死亡时自动调用

3、全局对象的构造和析构顺序

练习代码 test.cpp 中定义了一个全局对象:

#include <iostream>
using namespace std;

class Print
{
public:
    Print()
    {
        cout << "hello" << endl;
    }

    ~Print()
    {
        cout << "world" << endl;
    }
};

Print p;

int main()
{
    cout << "cpp" << endl;
    return 0;
}

输出结果:

hello
cpp
world

原因:

1. 全局对象 p 在 main 函数执行之前创建,所以先输出 hello
2. main 函数执行,输出 cpp
3. 程序结束时,全局对象 p 被销毁,所以最后输出 world

这个练习说明:不修改 main 函数,也可以通过全局对象的构造和析构,在 main 前后执行代码。


4、动态内存 new 和 delete

C++ 中,对象的内存大致分为三类:

局部对象              栈区
全局对象 / 静态对象    静态存储区
动态对象              堆区

C 语言中常用:

malloc();
free();

C++ 中处理对象时更推荐:

new;
delete;

原因是:new/delete 不只是分配和释放内存,还会自动调用构造函数和析构函数。

4.1、创建单个对象

Demo* p1 = new Demo(100);
p1->print();
delete p1;

执行过程:

new Demo(100)  分配内存,并调用构造函数
p1->print()    通过指针调用成员函数
delete p1      调用析构函数,并释放内存

注意:

p1->print();

等价于:

(*p1).print();

-> 是通过对象指针访问成员时使用的运算符。

4.2、创建对象数组

Demo* p2 = new Demo[5];
delete []p2;

注意匹配关系:

new 单个对象       对应 delete
new [] 对象数组    对应 delete []

错误示例:

Demo* p = new Demo[5];
delete p; // 错误,应该使用 delete []

一句话记忆:

怎么 new,就怎么 delete;带 [] 的释放时也要带 []。

5、nullptr 空指针

传统 C/C++ 中经常用 NULL 表示空指针,但在 C++ 中,NULL 有时会造成函数重载匹配不清楚。

示例:

void foo(int x);
void foo(int* p);

// foo(NULL); // 可能产生二义性
foo(nullptr); // 明确表示空指针

nullptr 是 C++11 引入的空指针字面值,专门表示空指针。

对比:

NULL       本质上通常是 0,可能被当作整数
nullptr    专门表示空指针,类型更明确

建议:

C++ 中表示空指针,优先使用 nullptr。

6、双向链表 List

今天的 list.hpplist.cpp 用类封装了一个简单的双向链表。

链表类的主要成员:

class List
{
public:
    List();
    ~List();

    void push_back(Type x);
    void push_front(Type x);
    void pop_back();
    void pop_front();

    Type back() const;
    Type front() const;

    int size() const;
    bool empty() const;
    void print() const;

private:
    struct Node
    {
        Node(Type data);
        Type _data;
        Node* _next;
        Node* _prev;
    };

    Node* _head;
    Node* _tail;
    int _size;
};

成员含义:

_head   指向第一个结点
_tail   指向最后一个结点
_size   记录链表中结点个数
_next   指向下一个结点
_prev   指向上一个结点

6.1、push_back 尾插

void List::push_back(Type x)
{
    Node* p = new Node(x);

    if (_size == 0)
    {
        _head = p;
        _tail = p;
    }
    else
    {
        p->_prev = _tail;
        _tail->_next = p;
        _tail = p;
    }

    ++_size;
}

逻辑:

1. 先 new 一个新结点
2. 如果链表为空,新结点同时是头和尾
3. 如果链表不为空,把新结点接到原来的尾结点后面
4. 更新 _tail
5. _size 加 1

6.2、push_front 头插

void List::push_front(Type x)
{
    Node* p = new Node(x);

    if (_size == 0)
    {
        _head = p;
        _tail = p;
    }
    else
    {
        p->_next = _head;
        _head->_prev = p;
        _head = p;
    }

    ++_size;
}

逻辑和尾插类似,只是插入位置变成链表头部。

6.3、pop_back 和 pop_front

删除结点时要注意三件事:

1. 空链表不能删除
2. 删除结点前,要先保存要删除的结点指针
3. delete 后,要修正 _head、_tail、_next、_prev

尾删:

void List::pop_back()
{
    if (_size == 0)
    {
        return;
    }

    Node* p = _tail;
    _tail = p->_prev;
    delete p;
    --_size;

    if (_size == 0)
    {
        _head = nullptr;
        _tail = nullptr;
    }
    else
    {
        _tail->_next = nullptr;
    }
}

6.4、链表和析构函数

链表中的每个结点都是通过 new Node(x) 创建的,所以最终应该在析构函数里释放所有结点。

推荐写法:

List::~List()
{
    while (!empty())
    {
        pop_front();
    }
}

这样当 List 对象销毁时,链表中的所有动态结点都会被释放。


7、字符串类型 std::string

C++ 标准库提供了 std::string 表示字符串。

使用前包含头文件:

#include <string>

常见创建方式:

std::string s1;           // 空字符串
std::string s2("hello");  // 使用 C 字符串初始化
std::string s3{"hello"};  // C++11 大括号初始化
std::string s4 = "hello"; // 直接赋值初始化
std::string s5(5, 'a');   // "aaaaa"

今天代码中的常用接口:

std::string s1 = "hello";

cout << s1.size() << endl;  // 字符个数
cout << s1.empty() << endl; // 是否为空

const char* str = s1.c_str(); // 转成 const char*

size_t index = s1.find("oo"); // 查找子串
if (index == std::string::npos)
{
    cout << "not found" << endl;
}

7.1、[] 和 at() 的区别

cout << s1[10] << endl;    // 越界时结果未知
cout << s1.at(10) << endl; // 越界时会抛出异常

简单理解:

[]      速度快,但不检查越界
at()    会检查越界,更安全

7.2、find 和 npos

size_t index = s1.find("oo");

如果找到,返回子串第一次出现的位置;如果没找到,返回:

std::string::npos

判断方式:

if (index == std::string::npos)
{
    cout << "not found" << endl;
}

8、目录操作 Dir 类

dir.hppdir.cpp 把 Linux 目录读取封装成了一个 Dir 类。

相关头文件:

#include <dirent.h>

核心函数:

opendir()   打开目录
readdir()   读取目录项
closedir()  关闭目录

类的声明:

class Dir
{
public:
    Dir(std::string path);
    ~Dir();
    List getAllFilenames();

private:
    std::string _path;
    DIR* _pdir;
};

8.1、构造函数打开目录

Dir::Dir(std::string path) : _path(path)
{
    _pdir = opendir(_path.c_str());
    if (_pdir == nullptr)
    {
        perror("opendir error");
        exit(1);
    }
}

说明:

_path.c_str() 把 std::string 转成 const char*
opendir() 打开成功会返回 DIR*,失败返回 nullptr

8.2、析构函数关闭目录

Dir::~Dir()
{
    closedir(_pdir);
}

这个例子很好地体现了析构函数的作用:

构造函数负责打开资源
析构函数负责释放资源

8.3、读取所有文件名

List Dir::getAllFilenames()
{
    List list;

    while (1)
    {
        struct dirent* p = readdir(_pdir);
        if (p == nullptr)
        {
            break;
        }

        std::string pathfile = _path + p->d_name;
        list.push_back(pathfile);
    }

    return list;
}

使用方式:

Dir dir("./");
List list = dir.getAllFilenames();
list.print();

注意:

Dir 这部分代码依赖 Linux/POSIX 的 dirent.h,在 Windows 原生环境下可能不能直接编译。

9、Day4 总结

1. explicit 用来禁止构造函数参与隐式类型转换。
2. 析构函数写成 ~类名(),对象销毁时自动调用。
3. 构造函数适合申请或打开资源,析构函数适合释放或关闭资源。
4. new 创建动态对象,会调用构造函数。
5. delete 释放动态对象,会调用析构函数。
6. new[] 要配 delete[],new 要配 delete。
7. C++ 中空指针优先使用 nullptr,不推荐继续用 NULL。
8. 链表结点通常用 new 创建,删除时必须 delete。
9. std::string 比 C 字符串更方便,常用 size、empty、at、c_str、find。
10. opendir/readdir/closedir 可以读取 Linux 目录内容。

一句话记忆:

explicit 管转换,析构函数管收尾,new/delete 管堆内存,string 管字符串,Dir 类把目录操作封装成对象。

"When faced with uncertainty, ask the spring breeze."