Linux C++快速入门

本文旨在以最小的篇幅,最少的信息,介绍最高频使用的内容,从而掌握C++编程开发的能力。

这种能力,只是语法层面,不涉及具体的函数库,基础库等内容。

能力准备:需要C语言基础。基本的if else, while,基础数据类型等等,不在本文涉及的范围之内。

环境准备

感谢微软的努力,让我们在 Windows环境下可以毫无障碍的进行Linux开发。

推荐使用Windows + wsl2 的环境开发。

打开Microsoft Store,搜索ubuntu,安装最新版本即可。

安装如有问题,请自行百度

使用 vscode + wsl插件的形式,编辑、编译代码。ctrl + ` 可以在命令行和文件编辑之间切换,非常的方便。

在ubuntu下,安装cmake, gcc, g++。安装方法自行百度。

代码的组织结构

build : 编译目录
docs : 文档目录,负责存放该代码相关的信息
libs : 该项目依赖的外部的库及头文件
libs/include 依赖库的头文件
libs/[编译器名称] 平台相关的库文件。比如cc放x86-64位的库,arm-linux-gnu-gcc放该编译器编译出的相关的库文件
source 项目源码目录
test 测试目录,内含测试代码

本文中所有涉及到的示例代码,可在此下载:

链接:https://pan.baidu.com/s/1f73k5uxYTRgtMEORbvgqvA?pwd=tnje

提取码:tnje

编译方法:

cd build
cmake ..
make

该代码展示了一个功能库的目录结构,编译方法。

如果想要做成可执行程序,参考test目录中的内容即可。

类的基本规则

通常定义一个类,我们会分为源文件.cpp,和头文件.h分开来用。

如下为头文件。其中的注释请仔细阅读

// rtspc.h
/**
 * @author 
 * @brief rtsp客户端
 * @version 0.1
 * @date 2023-11-30
 * 
 * @copyright Copyright (c) 2023
 * 
 */
// 使用pragma once 让头文件只引用一次。与下面的 _RTSPC_H_ 作用一致 
#pragma once

// 头文件避免重复引用。与#pragma once 二选一
#ifndef _RTSPC_H_
#define _RTSPC_H_

#include <string>
#include <functional>

// 注意,原则上禁止在头文件中使用using namespace xxx。避免命名空间失效
// 实际上,不管源文件和头文件,都不建议using namespace的方式。而是直接写全。
// using namespace std;

// 这是命名空间。可以有效隔离类,函数,变量的重名问题。在定义库时都建议添加使用。
namespace rtsp
{

class Rtspc
{
// 公共方法,类外部可访问
public:
    // 回调函数新写法。对应lambda表达式使用
    using OnData = std::function<void(const char *data, int len)>;
    Rtspc(bool btcp, OnData onData);
    // 注意,如果该类会被继承,则务必将它写成虚函数。否则影响析构
    virtual ~Rtspc();


    // 建议安装doxygen插件,在函数上方输入 /// 或者 /** 自动生成注释模板。

    /// @brief 公共方法,大写开头。私有方法,小写开头(代码规范,自行约定)。
    /// @param url 
    /// @param bTCP 
    /// @return 
    int Run(const std::string &url, bool bTCP);

    /// @brief 停止
    /// @return 
    void Stop(){
        _running = false;
    }

// 保护方法。类内及类的子类可访问。
protected:
    // 对于不改变类的内容的方法,后面加const
    // 对于不希望被改变的返回的引用,前面加const
    const std::string &getValue() const {return _url;}

// 私有方法。通常它和私有成员的private分开写,更清晰一些。
private:
    void workthread();

// 私有成员。成员变量通常为私有成员
private:
    std::string _url; // 成员变量以 '_' 开头,以便代码中与局部变量,参数做区分。
    bool _btcp;
    bool _running; 
    OnData _onData;
};

#endif //_RTSPC_H_

} // namespace rtsp

源文件长这样。

// rtspc.cpp
#include <iostream>
#include <chrono>
#include <thread>
#include "rtspc.h"

namespace rtsp
{

// 构造函数
Rtspc::Rtspc(bool btcp, OnData onData)
// 这下面是类成员初始化的写法。据说比写在大括号里效率要高
:_btcp(btcp)
,_running(true)
,_onData(onData)
{
    
}

Rtspc::~Rtspc()
{
}

int Rtspc::Run(const std::string &url, bool bTCP)
{
    std::cout << "Running " << url << std::endl;
    const std::string data = "haha, i am data";
    while (_running)
    {
        std::this_thread::sleep_for(std::chrono::milliseconds(400));
        _onData(data.c_str(), data.size());
    }
    
    return 0;
}


} // namespace rtsp

继承与虚函数

干货都在代码中

//rtp-pack.h 这是父类
#pragma once

#include <memory>
#include <functional>

namespace rtsp
{

class RtpPack
{
public:
    // 回调打包好的数据
    using OnRtpData = std::function<void(const std::string &rtp)>;
    using Ptr = std::shared_ptr<RtpPack>;
    RtpPack(OnRtpData onRtp)
    :_onRtp(onRtp)
    {}
    // 注意,如果该类会被继承,则务必将它写成虚函数。否则影响析构
    virtual ~RtpPack(){}

    virtual int Pack(const uint8_t *data, int len) = 0;

    /// @brief 创建打包器
    /// @param encode 编码方式
    /// @return 
    static Ptr CreatePacker(const char *encode, OnRtpData onData);
protected:
    OnRtpData _onRtp;
};

} // namespace rtsp

// rtp-pack-h264.h 这是子类
#pragma once

#include <rtp/rtp-pack.h>

namespace rtsp
{
class RtpPackH264 : public RtpPack
{
private:
    /* data */
public:
    RtpPackH264(RtpPack::OnRtpData onRtp)
    :RtpPack(onRtp)
    {}
    virtual ~RtpPackH264(){}

    virtual int Pack(const uint8_t *data, int len) override
    {
        std::string rtp;
        rtp.append("begin flag");
        rtp.append((const char *) data, len);
        _onRtp(rtp);
        return 0;
    }

    // 非虚函数
    int Demo()
    {
        return 0;
    }
};

} // namespace rtsp

如代码所示,RtpPackH264为子类,它继承于RtpPack

虚函数

其中,Pack 在RtpPack中,被定义为纯虚函数。这意味着你无法将RtpPack实例化。

也就是说,RtpPack pack; 是非法的。

只有在子类中实现了 Pack方法,就像RtpPackH264 一样,它才能够被实例化。

std::function 与lambda表达式

它是C++11开始支持的好东西,它有两个作用:

1,替代回调函数

2,替代回调函数的 param回传参数。

最重要的是第二点,结合lambda函数使用,让我们的代码看起来是如此的与众不同。

请参看如下示例。

testrtp.cpp

#include <rtp/rtp-pack-h264.h>
#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int main()
{
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    connect(sock, xxx);

    // std::function 的定义与 lambda的应用
    rtsp::RtpPackH264 rtp([sock](const std::string &rtp){
        send(sock, rtp.c_str(), rtp.size());
    });
    
    while (1)
    {
        rtp.Pack("123456", 6);
    }
    return 0;
}

可以看到,RtpPackH264 rtp 在实例化的时候,传的参数是一个奇怪的东西:[sock](const std::string &rtp){xxx

这个奇怪的东西,叫作lambda表达式。也叫匿名函数。

[]内部,就相当于我们注册回调函数时,注册进去的param,它通过回调函数再传回给我们。

而这里则不需要这么麻烦,你可以在[ ] 中加入任意多的变量,然后就如代码中的sock一样,在lambda体中使用。

需要注意的是,[sock]这是值传递的写法。它会记录sock的值。还可以这样写:[&sock],引用传递。此时需要注意,它相当于记录了sock的指针。

这里还有另一种写法,可以将lambda表达式写成一个变量:

    auto onRtp = [sock](const std::string &rtp){
        send(sock, rtp.c_str(), rtp.size());
    };

    rtsp::RtpPackH264 rtp(onRtp);
    

小提示:

本节中的代码,没有源文件。类的定义与实现,可以都写在头文件中,只不过这要看实际情况而写。

它的缺点是编译、链接较慢,封装性差。

但有些时候,比如模板,必须写在头文件中。

类的本质是什么?

C++中的类,命名空间,虚实函数,本质上都可以用C来表达。或者换个说法,C++编译器最终会把它变成C语言那样的东西。

就拿RtpPackH264来讲,它在编译器处理后,变成了如下的东西。至于C++的各种特性,都是语法糖。

rtp-pack-h264.c

#include <stdlib.h>
#include <stdint.h>

typedef void (*rtpCallback_t)(void *userparam, const uint8_t *data, int len);


struct rtpH264_class{
    // 类成员变量
    rtpCallback_t _onRtp;
    void *_userparam;
    
    // 虚函数表
    struct rtpH264VirtualFunctionTable{
        int (*Pack)(struct rtpH264_class *thiz, const uint8_t *data, int len);
    }functionTable;
};

// 虚函数的实现,对应rtsp::RtpPackH264::Pack
// 注意这奇怪的名字,param之后,列出了参数类型。这就是为什么C++允许重名但参数不同的函数。
int rtsp_RtpPackH264_Pack_param_u8_i32(struct rtpH264_class *thiz, const uint8_t *data, int len)
{
    return 0;
}

// 构造函数,生成对象时自动调用。无论是new,还是局部变量
struct rtpH264_class *rtsp_RtpPackH264_RtpPackH264_param_rtpCallback_t_void(rtpCallback_t onRtp, void *userparam){
    // 分配内存
    struct rtpH264_class *rtp = malloc(sizeof(struct rtpH264_class));
    // 构造虚函数表
    rtp->functionTable.Pack = rtsp_RtpPackH264_Pack_param_u8_i32;

    rtp->_onRtp = onRtp;
    rtp->_userparam = userparam;

    return rtp;
}

// 析构函数。在对象生命周期结束时,自动调用
void rtsp_RtpPackH264_del_RtpPackH264(struct rtpH264_class *rtp)
{
    free(rtp);
}

// 非虚函数的实现
int rtsp_RtpPackH264_Demo(struct rtpH264_class *thiz)
{
    return 0;
}

以上代码中可以看到,类的函数的名字,实际上是由“命名空间+类名+方法名+参数类型”以一定规则,形成的。

而构造函数,实际上是编译器在生成对象是,帮我们调用的。

虚函数表,是在构造函数中指向了各个实际的函数。(不准确,但按此理解无不利影响)

敲黑板

所以,非虚函数,是在编译时就确定了调用关系的。比如调用RtpPackH264::Demo,是在编译时就确定了要调这个函数。

虚函数,是在执行时,查表,确定虚函数表中,指向的是哪个函数,从而完成调用。

请仔细研读,对照上述c实现的类代码,与类本身的关系。

请思考如下代码,最终输出的是什么?

class A{
public:
    void running(){printf("A running\n");}
    virtual void VirtualFunc(){printf("A virtual func\n");}
};
class B: public A{
public:
    void running(){printf("B running\n");}
    virtual void VirtualFunc(){printf("B virtual func\n");}
};

void main()
{
    B b;
    A *a = &b;
    b.running();
    a->running();
    b.VirtualFunc();
    a->VirtualFunc();
}

搜索一下隐藏和覆盖,看看网上五花八门的解释,对照我们把C++的类改成C的写法,你能明白隐藏和覆盖是咋回事了吗?

还有lambda,std::function。

我们将lambda以基础的C++类的方式来实现,它是这样的:

rtp-pack-lambda.hpp (由 rtp-pack 的无lambda写法)

/**
 * @author LiuFengxiang (20451250@qq.com)
 * @brief 以C++的类模拟 lambda,捕获等行为
 * @version 0.1
 * @date 2023-12-01
 * 
 * @copyright Copyright (c) 2023
 * 
 */
#pragma once

#include <string>

namespace lambdaTest
{

// 代替 std::function<void(const std::string &rtp)>
// std::function<xxx> 实际上是模板实例化成了一个类,这个类会记录函数指针和lambda捕获的变量
class RtpPackFunc
{
public:
    typedef void (*OnRtpData)(RtpPackFunc *thiz, const std::string &rtp);
    RtpPackFunc(OnRtpData data):_onRtp(data){}
    virtual ~RtpPackFunc(){}
    void Call(const std::string &rtp){
        _onRtp(this, rtp);
    }
public:
    OnRtpData _onRtp;
};

class RtpPack
{
public:
    RtpPack(RtpPackFunc *callback)
    :_callback(callback)
    {}
    ~RtpPack(){}

    void Pack(){
        _callback->Call("haha");
    }
private:
    RtpPackFunc *_callback;
};


} // namespace lambdaTest

它的测试代码:testrtp-lambda.cpp, (由testrtp.cpp转化而来)

/**
 * @author LiuFengxiang (20451250@qq.com)
 * @brief 对应testrtp.cpp,我们不使用lambda,而是改用基础的类来实现
 * @version 0.1
 * @date 2023-12-01
 * 
 * @copyright Copyright (c) 2023
 * 
 */

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
#include <unistd.h>

#include <rtp/rtp-pack-lambda.hpp>

using namespace lambdaTest;

// 实际的实现并不相同,但是这样写起来优雅一点儿,也并不妨碍理解。
class MyRtpPackFunc :public RtpPackFunc
{
public:
    MyRtpPackFunc(RtpPackFunc::OnRtpData data, int sock)
    :RtpPackFunc(data)
    ,_sock(sock)
    {}
    virtual ~MyRtpPackFunc(){}
public:
    int _sock;
};

// 编译器将lambda表达式生成了回调函数
static void MyOnRtpData(RtpPackFunc *func, const std::string &rtp)
{
    printf("%s running, data: %s\n", __func__, rtp.c_str());
    MyRtpPackFunc *mine = dynamic_cast<MyRtpPackFunc *> (func);
    if (mine != nullptr)
    {
        // 不能真发,没准备好呢
        if (0)
            send(mine->_sock, rtp.c_str(), rtp.size(), 0);
    }
}

int main()
{
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    // connect(sock, xxx);

    /*在使用lambda时,编译器干了很多事情:
         1,将匿名函数以自有的规则命名(objdump可以看一下,巨长),这里是 MyOnRtpData
         2,将 std::function模板实例化,相当于 MyRtpPackFunc
         3,将实例化的类生成对象,也就是这里的 func, 并传入初始化的两个参数: MyOnRtpData, sock
    */ 
    MyRtpPackFunc *func = new MyRtpPackFunc(MyOnRtpData, sock);

    // RtpPack记录的,实际上就是 std::function 的对象: func
    RtpPack rtp(func);
    
    while (1)
    {
        rtp.Pack();
        usleep(1*1000*1000);
    }

}

为了简化写法,我们帮编译器翻译的并不精确,但这并不妨碍理解。

你就记住:lambda表达式,就是编译器帮你起名的匿名函数。而std::function 则是编译器帮你生成的类。

所谓捕获,同样没什么神奇之处,值捕获,在类中直接记录了该变量的值,引用捕获,则是在类中记录了该类的指针。

思考:值捕获和引用捕获的变量,它们的生命周期是怎样的?

本节是想告诉你,C++的很多规则,并不是人为制订出来的,而是语言本身的实现上,必须这么做。它的因果关系是:因为这门语言是这样设计的,所以,产生了这样的规则。

本节只是个引子,借此提示。

多思考!

多思考!!

多思考!!!

多思考背后的机理,那才能举一反三,抓住本质。

记住这句话:C++的所有规则,都是因为设计时,只能这样做。

最常用容器

std::string 其实也是容器。但是我们把它当成一个普通类用就好了

vector 是数组容器。用来管理数量不定的同类型的内容

map 相当于一个映射表,key,value的形式。通过key可以快速的查找到对应的值。

如下代码展示了一些常用的函数,更详细内容查文档:DevDocs

/**
 * @author LiuFengxiang (20451250@qq.com)
 * @brief 介绍常用容器的用法
 * @version 0.1
 * @date 2023-12-01
 * 
 * @copyright Copyright (c) 2023
 * 
 */

#include <vector>
#include <string>
#include <map>
#include <iostream>

#include <memory> // 智能指针相关

class Node
{
public:
    // 智能指针的技巧。简化写法
    // 使用的时候 Node::Ptr p 相当于 std::shared_ptr<Node> p
    using Ptr = std::shared_ptr<Node>;
    Node(int val, const std::string &str)
    :_val(val)
    ,_str(str){}
    virtual ~Node(){}

    const std::string &Str()const {return _str;}

    int Val()const {return _val;}

    void SetVal(int val){_val = val;}

private:
    int _val;
    std::string _str;
};


static void vectorDemo()
{
    // 使用智能指针代替Node * 或者 直接Node。
    // 如果用指针,在释放时必须逐个 delete。一但遗漏就会有内存泄漏
    // 如果直接用Node,则每次加入时都会产生拷贝动作。
    std::vector<Node::Ptr> vec;
    for (int i = 0; i < 10; i++)
    {
        auto node = std::make_shared<Node>(i, "haha");
        vec.push_back(node);
    }
    
    // 遍历,并按条件删除
    for (auto it=vec.begin();it!= vec.end();)
    {
        if ((*it)->Val() == 5)
        {
            // 删除成员时,不能直接it++
            it = vec.erase(it);
        }
        else
        {
            it++;
        }
    }

    // 另一种遍历方式
    for (auto &&it : vec)
    {
        printf("node val: %d, str: %s\n", it->Val(), it->Str().c_str());
        it->SetVal(it->Val()+1);
    }

    // 注意,如果是 std::vector<Node *> vec, vec.clear() 执行时,并不能自动对每个node做delete动作。
    // 所以此时,你需要先逐个 delete ,再行 clear
    vec.clear();
}

// map 主要是为了快速通过key 来找到对应的内容。key可以是基础类型,string
// 如果要把自定义的类作为key,则需要自定义比较函数
void mapDemo()
{
    std::map<int, Node::Ptr> _map;
    for (int i = 0; i < 10; i++)
    {
        auto node = std::make_shared<Node>(i, "haha");
        _map.emplace(i, node);
    }

    // 查找
    int key = 5;
    auto it = _map.find(key);
    if (it != _map.end())
    {
        std::cout << "we Found it, key: " << it->first << ", str: " << it->second->Str() << std::endl;
    }
    else
    {
        std::cout << "We Failed found with key: " << key << std::endl;
    }
    
    // 遍历,并按条件删除
    for (auto it=_map.begin();it!= _map.end();)
    {
        if (it->first == 5)
        {
            // 删除成员时,不能直接it++
            it = _map.erase(it);
        }
        else
        {
            it++;
        }
    }

    // 另一种遍历方式
    for (auto &&it : _map)
    {
        std::cout << "node key: " << it.first << ", val: " << it.second->Str() << std::endl;
        it.second->SetVal(it.second->Val()+1);
    }
}

int main()
{
    vectorDemo();
    mapDemo();

    return 0;
}

智能指针的使用

智能指针,是现代C++编程非常重要的一个特性。

实际上,有了智能指针之后,我们不应该再使用裸指针了。

下面罗列几个主要的使用场景:

1,配合容器使用

比如有一个类Car,它有很多成员。

如果定义std::vector<Car> _carvec,它的问题是:

Car需要可拷贝,有可能需要实现拷贝构造函数

Car是拷贝了多份的,是独立的。它们之间互相完全无关。

而如果使用指针 std::vector<Car *> _pcarvec。

那你需要注意的是:插入前要new Car, 擦除前要先 delete 成员。

最容易忘的是_pcarvec.clear(). 这个方法执行前,你需要先遍历,逐个delete car

此时,更方便的用法是:std::vector<std::shared_ptr<Car>> _shCarVec;

2,回调函数中使用weak_ptr

(weak_ptr的概念可以先百度一下。)

回调函数有个比较大的问题是,当回调上来之后,数据的消费者可能已经被销毁了。这时我们的指针,是否还生效?如何判断?

如下代码中,rtspc的回调,数据上来之后,窗口是否还存在?

这里,我们通过保存它的weak_ptr句柄,使用时,通过lock的形式来处理。

只要lock成功了,weak_ptr将会升级成为强引用,说明对象还在,我们就可以正常输入数据。

//rtspclient.cpp
#include <memory>
#include <map>
#include <vector>
#include <mutex>
#include <thread>
#include <rtspc/rtspc.h>


// 解码窗口
class MyWindow
{
public:
    using Ptr = std::shared_ptr<MyWindow>;
    MyWindow(int winid)
    :_winid(winid)
    {}
    ~MyWindow(){
        printf("window %d destroyed\n", _winid);
    }

    int InputMediaData(const char *data, int len){
        // printf("input data in window: %d\n", _winid);
        return 0;
    }
private:
    int _winid;
};

class WindowMgr
{
private:
    WindowMgr(/* args */){}
    ~WindowMgr(){}
public:
    // 单例
    static WindowMgr& Instance(){
        static WindowMgr _inst;
        return _inst;
    }
    void SetWindowCnt(int cnt){
        std::lock_guard<std::mutex> guard(_mutex);
        // 注意哦,这里窗口重建了
        if ((size_t)cnt != _windows.size())
        {
            _windows.clear();
            // 延时,扩大rtspc上回调时窗口销毁的概率
            std::this_thread::sleep_for(std::chrono::milliseconds(1000));
            for (int i = 0; i < cnt; i++)
            {
                _windows.push_back(std::make_shared<MyWindow>(i));
            }
        }
    }

    MyWindow::Ptr GetWindow(int winid){
        std::lock_guard<std::mutex> guard(_mutex);
        if ((size_t)winid >= _windows.size())
        {
            return nullptr;
        }
        return _windows[winid];
    }

private:
    std::vector<std::shared_ptr<MyWindow> > _windows;
    std::mutex _mutex;
};

static void PlayInWindow(int winid, const char *url){
    std::weak_ptr<MyWindow> weak = WindowMgr::Instance().GetWindow(winid);
    rtsp::Rtspc rtspc(true, [&rtspc, weak](const char *data, int len){
        std::shared_ptr<MyWindow> strongPtr = weak.lock();
        if (strongPtr == nullptr)
        {
            printf("window destroyed, exit rtspc\n");
            rtspc.Stop();
        }
        else
        {
            strongPtr->InputMediaData(data, len);
        }
    });
    rtspc.Run(url, true);
}

static void SetWindowCnt(int winCnt)
{
    printf("Now win cnt: %d\n", winCnt);
    WindowMgr::Instance().SetWindowCnt(winCnt);
    for (int i = 0; i < winCnt; i++)
    {
        std::thread([i](){
            char url[256];
            snprintf(url, sizeof(url), "rtsp://192.168.1.2:554/live/chn%d", i);
            PlayInWindow(i, url);
        }).detach();
    }
}

int main(int argc, const char *argv[])
{
    int count = 4;
    while (true)
    {
        SetWindowCnt(count);
        getchar();
        count++;
    }

    return 0; 
}

可能还有同学有疑问,如果strongPtr拿到之后,在InputMediaData执行之前,发生了窗口切换怎么办呢?

这完全无须担心,由于我们已经持有了window的强引用,此时它并不会被销毁。只有等我们InputMediaData执行完之后,rtspc的回调函数执行完,strongPtr的生命周期完结,此时智能指针的计数清零,MyWindow才会得到释放。

3,类成员指针

类成员指针,它的重建,需要先delete老的。析构时,也需要析构。

而使用了智能指针,这些工作都不需要做了

如下代码中,智能指针_packer,构造函数中的创建,ChangePacker函数中把它重新赋值,都不需要考虑销毁。因为智能指针会自动析构老的内容。

同时,~Rtsps()析构函数执行时,也不需要手工析构_packer。

/**
 * @author LiuFengxiang (20451250@qq.com)
 * @brief rtsp 服务端
 * @version 0.1
 * @date 2023-12-9
 * 
 * @copyright Copyright (c) 2023
 * 
 */
#pragma once
#include <string>
#include <rtp/rtp-pack.h>

namespace rtsp
{

class Rtsps
{
public:
    Rtsps(/* args */){
        _packer = RtpPack::CreatePacker("H264", [this](const std::string &rtp){
            onRtpData(rtp);
        });
    }
    // 注意,如果该类会被继承,则务必将它写成虚函数。否则影响析构
    virtual ~Rtsps(){}

    /// @brief 更换打包器
    /// @param encode 打包器名称
    void ChangePacker(const char *encode){
        _packer = RtpPack::CreatePacker(encode, [this](const std::string &rtp){
            onRtpData(rtp);
        });
    }


    int Run(){
        return 0;
    }
private:
    void onRtpData(const std::string &rtp){
        printf("onRtpData\n");
    }

private:
    // 打包器的句柄。
    RtpPack::Ptr _packer;
};
 
} // namespace rtsp
  • 23
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值