2004年十一月月 发布的文章

C++ Advanced Training(二)

今天侯老师花了2个小节的时间把昨天的“尾巴”讲完,然后就进入今天的正题OOP,注意是OOP,not OOD。

听了侯老师的两天课,感觉他的讲课风格是:
- 关注细节
- 以讲”故事”的方式来讲解抽象的技术。    

我将继续接上一节的内容谈C++。

1、Increment operator(++)
++ operator分为 ++A 和A++两种,实际在实现中A++调用了++A。我们举个例子

class Fraction
{
    Fraction& operator++();
    Fraction& operator++(int);
}

inline Fraction& operator++()
{
    m_numerator += m_denominator;
    return *this;
}

inline Fraction& operator++(int)
{
    Fraction oldValue = *this;
    ++(*this);     // call the prefix increment
    return oldValue;    //why?
}

从以上的代码段中我们可以得到两个结论:
1)从代码可以看出在使用++ operator时,特别是对自定义类型的++时,尽量选用++A型,因为A++在实现中实际上是调用A++,所以A++型要比++A型执行速度慢。
2)我们在设计数值型class时,最好以int为参照物。这也是为什么Fraction& operator++(int)返回oldValue的原因。我们举例说明在使用primitive type int时,++的用法:

int a = 5;
int b = a++;
cout << a << endl;    // a = 6
cout << b << endl;    // b = 5

可见A++型,是先返回A的值,再做++操作。所以我们在自定义数值型class的时候也要模拟这种方式,使++ operator的使用方式保持一致,无论对primitive type 还是user-defined type。

2、scope and lifetime
这里总结以下各种object的lifetime:
global object                    program始 ,program终
local(auto) object                scope始 , scope终
heap(dynamic allocated ) object    new始 , delete终
static local object                scope始 ,program终

说明:
1)global object的建构是在main之前所以利用global object的ctor可以帮助你做一些有用的事,MFC就利用了这点完成了许多有用的操作。
2)在program终止之前(即在main函数执行结束之前),有global object , static local object at somewhere 和local object in main等的dtor会被调用。但是次序不定(视编译器实作方式而定),下面代码列出VC++7.1的做法:

#include "iostream"
#include "string"
using namespace std;

class Test2
{
    public:
        Test2(const string& str) : m_name(str)
        {
            cout << "constructor called for " << m_name << endl;
        }
        ~Test2()
        {
            cout << "destructor called for " << m_name << endl;
        }
    private:
        string m_name;
};

void g_func()
{
    static Test2 l_TestObj1("StaticLocalObjInGlobalFunc");
}

Test2 g_TestObj("GlobalObj");

int main(int argc, char *argv[])
{
    Test2 l_TestObj2("LocalObjInMain");
    g_func();
    return 0;
}

Output:
constructor called for GlobalObj
constructor called for LocalObjInMain
constructor called for StaticLocalObjInGlobalFunc
destructor called for LocalObjInMain
destructor called for StaticLocalObjInGlobalFunc
destructor called for GlobalObj

3、static member

1)static data members
独立于objects之外,众多objects共享一份static data members,也就是说每个class只有一份;
static data members可被继承(其access level)。

2)static member function的特点
没有this pointer ,因此就像non-member function一样;
必定不为virtual;
可以不通过object而直接访问(通过类的全名,如Accout::setRate())。

3)static member function的用途
用于处理static data member;
用于callback function。
static member function用于处理static data member无可厚非,我们也不必细讲,关键是为什么使用static member function来用于callback,为什么不直接是用non-static member function?

首先我们要知道什么是callback function?callback function是如何运行的?callback中文译为“回调”,台湾译为“回呼”,我们拿一个实际的例子来解释什么是callback , callback function是如何工作的?

在Window平台上开发GUI应用程序时,我们会常常用到一个Win32 API,其原型如下:
BOOL LineDDA(
    int nXStart,             // x-coordinate of line's starting point
    int nYStart,             // y-coordinate of line's starting point
    int nXEnd,               // x-coordinate of line's ending point
    int nYEnd,               // y-coordinate of line's ending point
    LINEDDAPROC lpLineFunc,  // pointer to callback function
    LPARAM lpData            // pointer to application-defined data
);

这个函数的用途在msdn中被描述为 “The LineDDA function determines which pixels should be highlighted for a line defined by the specified starting and ending points. ”这个函数是做什么的我们不关心,我们关心的是它的第5个参数,这是一个LINEDDAPROC类型的函数指针,也就是说我们要使用LineDDA这个函数就必须传入一个函数地址,这是因为LineDDA在执行过程中有些动作不能确定,需要我们来告诉它怎么做,我们如何告诉它呢,就通过传入这个有着固定signature的函数的地址,而这个被LineDDA所使用的函数就叫做callback function。callback function的signature是事先定义好的,包括参数的类型和个数等。

下面我们我们就利用这个来解释为什么non-static member function不能作为callback function了。我们都知道一个class的non-static member function在被调用时,编译器会将this这一隐藏的指针加入到该funtion的参数列表中去,导致参数的个数增加而不符合callback预先定义好的signature。而static member function不含有this这一隐藏指针,所以完全胜任callback function这一角色。

4)static member function、non-static member function 、static data member和non-static data member之间的关系

告诉大家一个总的原则,理解上述几个member关系的关键在于this指针,具体地说:
- non-static member function既可以调用static member function,也可以处理static data member;
- static member function则既不能调用non-static member function,也不能处理non-static data member。

4、new expression(new operator)&operator new
new operator和operator new这两个东西让一些初学者感到不能理解,甚至包括一些用过很长时间C++的老手都很可能被迷惑,这两个到底有什么区别?各自代表什么意思呢?

我们举个例子大家就清楚了。
Complex* pc = new Complex(1,2);    //这句代码里的new就是new operator,它是C++ 的一个关键字,当这条语句执行时,编译器会执行一系列动作。依次为:
- 调用::operator new分配内存空间;
- casting(转型)
- invoke Complex的constuctor

其中第一步调用::operator new分配内存空间中的::operator new就是我们所说的后者,它是真正分配内存的执行者,相当于C中的malloc函数,与malloc不同的是::operator new可以被重新定义,你可以定义你自己class专用的operator new函数。为什么我们要这么做呢?因为使用默认的::operator new分配每一块内存的同时也会分配一块叫cookie的内存块用来存放一些帮助内存管理的信息,如分配的内存的大小,供delete使用。在一些embeded system中,memory是limited的。我们要尽量减少cookie的分配,所以我们要定义自己的operator new。比如我们可以事先分配一大块内存,以后再需要动态分配内存时,就在这个大块内存中再分配出来既可。

operator new 在对象产生之前被调用,所以必须是static的。(同理,operator delete在对象被销毁后被调用,也应该是static的),一般即使你不explicit的声明为static的,编译器也会自动默认为static的。

5、delete expression(delete operator)&operator delete
有了4中的new operator&operator new的基础,这节的东西就很好理解了。

关于delete pc,编译器会执行一系列动作,依次是:
- invoke Complex的destructor;
- 调用::operator delete释放内存空间。

::operator delete 等价于C的free函数。
::operator delete和::operator new类似也可以被重新定义你自己的版本。

下面举个例子(包含operator new 和operator delete)
class Base
{
    public:
        static void* operator new(size_t size);
        static void operator delete(void* rawMemory , size_t size);
};

void* Base::operator new(size_t size)
{
    if(size != sizeof(Base)) //大小错误,可能是被子类调用
        return ::operator new(size);//交给默认处理函数处理
    else
        //your code to alloc the memory
}

void Base::operator deletevoid* rawMemory , size_t size)
{
    if(rawMemory == 0) return;
    if(size != sizeof(Base)) //大小错误,可能是被子类调用
    {
        ::operator delete(rawMemory);//交给默认处理函数处理
        return;
    }
    else
    {
        //your code to free the memory
    }
}

main()
{
    Base *p = new Base();        //call the operator new which you defined
    delete p;    // call the operator delete which you defined
}

main代码中当编译器扫描到new时会看Base类中是否重新定义了operator new,如果是则调用Base专用的operator new。delete也是同理。

注:关于operator new &operator delete的一个原则就是:如果你写了一个operator new,就应该写一个对应的operator delete

下面是有关OOP的内容,侯老师认为学好OOP就要学好两方面:polymorphism和template method。

我的一个同事一直和侯老师争论下面的这两个概念的理解,这里我把我的理解写下来:

framework &application framework
framework—-    it is always a library which is large ,complex and have many classes and many associations among these classes. such as c++ library , Microsoft .net class library,Win32 API

application framework—- it have helped you define the skeleton of the application ,what you should do is only to override some virtual functions or add some business logic code , that is all。such as MFC ,VCL等。

6、SubObject and virtual destructor
我们看一个例子来说明subobject的概念和virtual destructor的用途。
CShape
  /|\
   |
CRect
  /|\
   |
CSquare

大家从上面的图中也会有所了解subobject的概念。在CSquare object中,既有CRect的suboject又有CShape的subobject。它们的构造顺序是:由内向外,而析构顺序为:由外向内。

如果有下面代码:
CRect* p = new CSquare();
delete p;

这时如果CRect的dtor为non-virtual的,上述的代码就相当于企图用一个拥有non-virtual dtor的base class的指针来删除一个derived class oject, 其结果是未定义的。最可能的是执行期未调用derived object的dtor, 因为compiler看到基类拥有的是non-virtual dtor,所以根据p的静态类型将dtor编死,而不经过虚拟机制的route。所以告诫如下:“总是让base class拥有virtual dtor”。这样通过虚拟机制route的编译会将derived类的dtor编进去,我们就能够通过基类指针销毁derived object了。

7、Template method
其实这是design pattern的内容,由于这个pattern比较好理解,所以侯老师把它拿到前面来了。

侯老师说理解这个关键在于理解library code(你用money买的) 和application code(你自己写的),心中在这两个code之间划一条线(见图中那条虚线),库代码都是固定的,不会因为你的业务逻辑而改变的。在库代码中一般都存在这样的函数,它的动作流程很规律,比如Windows应用程序的打开文件操作,流程不过是“打开文件对话框”、“选择文件类型和文件名”、“读入文件内容”等,无论事打开什么文件这个流程都不会改变,这类函数被称为template method。还是以打开文件这一动作为例,在该template method中我们要有一个函数负责读取文件的内容,而文件的类型多种多样,内容的格式也不相同,那我们如何在代码执行到这个读取文件函数(primitiveFunc)时能根据不同的文件类型执行不同的动作呢?我们利用polyphorism机制,见上面的图形,当main中的代码执行到a.TemplateMethod中的primitiveFunc的时候,代码将调用不同的子类override的那个primitiveFunc而不是库代码中实现的那个primitiveFunc。

8、Polymorphism vs static type &dynamic type

我个人认为学好polymorphism的关键在于:
1)看call through object 还是 call through pointer
2)static type or dynamic type

至于什么是多态,我这里就不多说了,任何一本C++教材都会有详细的讲解。
static type —- 变量声明时的type;
dynamic type —- 变量实际的type;

举例说明:
CShape* p ;
p = new CRect();

上述代码中指针p的static type为CShape* , 而dynamic type为CRect* 。

再看看下面代码:
class CShape
{
    public:
    virtual void draw()
    {
        cout << "Draw for CShape" << endl;
    }
};
class CRect : public CShape
{
    public:
    virtual void draw()
    {
        cout << "Draw for CRect" << endl;
    }
};
class CSquare : public CRect
{
    public:
    virtual void draw()
    {
        cout << "Draw for CSquare" << endl;
    }
};
int main(int argc, char *argv[])
{
    CShape* p;

    CShape s;
    s.draw();    //invoke CShape::draw()
    
    CRect rc1;
    rc1.draw();    //invoke CRect::draw()

    p = new CRect();
    p->draw();    //invoke CRect::draw()

    delete p;
    p = new CSquare();
    p->draw();    //invoke CSquare::draw()

    delete p;
    return 0;
}

Output:
Draw for CShape
Draw for CRect
Draw for CRect
Draw for CSquare

通过pointer去call function时,编译器会去查看该pointer的动态类型来决定到底调用哪个函数。如上述代码中的指针p,第一次被赋予一个CRect* 类型,通过p call draw时,compiler得知p的dynamic type为CRect* ,而不是CShape*,所以调用CRect::draw;同理第二次调用的是动态类型CSquare的draw。

通过obj调用function时比较简单,obj是什么类型的就调用哪个类型的draw即可。

9、Inside the object model
这里涉及到virtual pointer、virtual table等而且要画大量的图才能理解的更好,我倒觉得不如看看inside the c++ object model这本书,所以这里就不详细描述了(^_^其实我比较懒)。

10、virtual func vs non-virtual func vs pure virtual func

pure virtual func — 为了让derived class只继承其接口。
virtual virtual func — 为了让derived class继承该函数的接口和预设行为。
non-virtual func — 为了让derived class继承该函数的接口和实现(继承实现的前提是derived class没有hide该函数接口)。
 

C++ Advanced Training(一)

作为一名刚来公司不久的新员工,有幸参加由侯捷老师做的高级C++培训,真的是很高兴。从接触Programming以来,C++一直是自己的主打语言(虽然最近正在研究Java^_^)。一天的培训下来,收获还是蛮大的,侯老师的细致入微的讲解给我留下了很深的印象。将我所得到的东西与大家分享同时对我来说又是个复习的过程,不失为一箭双雕之举^_^。

这次的高级C++培训分3个部分,分别为C++&ADT(一个独立Class的设计经验),C++&OOP,OOD&Patterns,我将用几篇文章来写我的收获,由于我的水平也有限,特别是对OOD和Patterns的理解不是很透彻,所以难免会出现不正确的“论点”还希望大家多多指点。

一、侯老师的开场白
侯老师有一部很有名的著作叫“无责任书评”(在侯老师的acer笔记本(我的本本也是acer的^_^)中,壁纸就是这本书的封面),在业界很受欢迎。侯老师自己是技术作家,也出版各类的技术书籍,所以对书有着他独到的见解。由于我们是高级C++培训,所以这次侯老师主要向我们介绍了所谓的C++大系的书籍,简单列举如下:(按侯捷的分类方式)

- 百科全书:《Thinking in C++》、《C++ Primer》、《The C++ Programming language 》、《The C++ Standard Library》
- 专家经验:《Effective C++》、《More Effective C++》、《Exceptional C++》、《More Exceptional C++》
- 底层内幕:《多态与虚拟》、《深入探索C++对象模型》
- 设计相关:《Design Pattern》、《Large Scale C++ Design》

二、正文(主题)

1、从C的角度看object-based programming(基于对象编程)
侯老师从C开始我觉得还是很合适的,一是培训的对象大多使用C做开发(这也与我们公司的业务领域息息相关,老的C程序员也许不太喜欢转移到C++),二是这样做可以间接的把C++编译器的实现方法展现在我们面前,便于对C++作深入理解。

用C提供的语法简单模拟C++类的概念。

分析C能给我们提供什么:
- C的struct能对data进行封装;
- C的struct可使用function pointer来模拟member function

C不能给我们提供什么:
- C的struct不能提供对access level的支持。

这里我也举个例子(不是侯老师讲义中的例子)来模拟C++。

#include "stdio.h"

#define MANAGER 0
#define CLERK    1
#define MEWER    2

typedef struct tagPerson
{
    int age;
    int baseSalary;
    int jobType;
    int (*calcSalary)(struct tagPerson*);
    void (*printInfo)(struct tagPerson*);
}Person;

int calcSalary(Person* this)
{
    if(this->jobType == MANAGER)
        return this->baseSalary*10;
    else if(this->jobType == CLERK)
        return this->baseSalary*5;
    else
        return this->baseSalary;
}

void printInfo(Person* this)
{
    //print the person's info include jobType,salary and so on;
    printf("the person's age is %d \n" , this->age);
}

int main()
{
    Person aPerson={24,1000,MANAGER,&calcSalary,&printInfo};
    Person bPerson={23,1000,CLERK,&calcSalary,&printInfo};
    int salary = aPerson.calcSalary(&aPerson);
    printf("the aperson's salary is %d\n" , salary);
    aPerson.printInfo(&aPerson);
    salary = bPerson.calcSalary(&bPerson);
    printf("the bperson's salary is %d\n" , salary);
    aPerson.printInfo(&bPerson);
}

值得大家注意的地方我都用加粗的字体了,在上面程序中我们先将Person的实例化为对象aPerson和bPerson,并手工将他们的地址传给他们自己的函数指针成员来模拟C++的成员函数的调用。现实中C++编译器将我们这一手工过程自动化了并隐藏了起来,也就是C++在每个成员函数的参数类表中偷偷添加了该对象的地址指针this。看起来也挺好理解的,但是这仅仅是C对C++简单的模拟,C++所提供的强大的面向对象的特性是C所不能比拟的,其实归根结底来说还是思维方式的转变带来的巨大变化。

2、建议使用最新的标准C++ style
C++从诞生那天到现在已经有20多年了,这期间C++程序的style也经历几次大的变化,直至今日,我们提倡采用C++标准程式库的code style,无论是初学者还是老手,都应该这么做,与标准靠拢是最好的选择。这里不详细阐述了,C++的creator的主页上就有很详尽的说明该如何写Standard C++代码,更有其观点“treat the standard c++ as a new language”。这里仅举几个简单观点和例子:

- standard header files  #include <= #include #include <= #include
- namespace std
- try to use stardard library as possible as you can!

3、forward declaration

以前一直对forward declaration不是很理解,今天终于有所突破了,所以就写下来,希望对那些和我有同样困惑的朋友们有所启发和帮助。看下面的两段代码:

代码段1:

class A; //forward declaration
class B
{
    //….
    A* a1;
    A* a2;
};

代码段2:

class A; //forward declaration
class B
{
    //….
    A a1;
    A a2;
};

直接告诉大家结论:代码段1顺利通过编译;代码段2则编译失败。

原因分析:

代码段1:由于B中的两个数据成员都是A*指针类型,指针类型在32位平台上大小都是4byte,编译器无需知道A的具体大小。那为什么还要有class A; //forward declaration这行代码呢,是因为编译器要知道代码中是否存在A这个类型,这行代码就是告诉编译器“你放心编译吧,这个A类型存在”。而代码段2的B中的两个数据成员都是A类型,编译器必须知道A的具体大小,仅仅告诉编译器A类型的存在是远远不够的。

4、function signature &function prototype(注意有特例)
Function prototype即是函数在声明时的所有元素的集合,包括函数名字,返回类型,参数列表;Funcition signature则是function prototype去掉返回类型后的剩余部分。

对于这两个概念我们还是举例说明比较直观,看下面的例子:

Function prototype:double calcSalary(Person* person);
Function signature:clacSalary(Person* person);

是否能够很好的区分这两个概念会直接关系到你对成员函数overloading的理解。牢记Overloading关注的是function signature 而不是function prototype!但是这里有个特例,那就是“pass by value和pass by reference是不同的signature么?”答案:不是。我们也可以举例说明这点,看下面的例子。

class A
{
    Public:
        int getArea(Circle& cir);
        int getArea(Circle cir);
};

main()
{
    Circle aCir;
    A a;
    a.getArea(aCir);//Ambiguous
}

某些编译器在class A的编译时并不报错,但是在真正调用时,发生模棱两可。

还有一个问题就是“为什么overloading不关心返回值类型呢”?我们还是举例说明一下:

class A
{
    Public:
        int PrintInfo(Person& a);
        void PrintInfo(Person& a);
};

main()
{
    Person aPerson;
    A a;
    a.PrintInfo(aPerson); //Ambiguous
}

说明:有些时候我们并不关心返回值,就像上例中的代码。一旦返回值类型可以作为overloading的一个评判依据,某些时候会造成模棱两可的错误。

5、尽量以const和inline替换#define(即macro)
这是个老话题,又是一个大家都容易犯的问题,这里就允许我再提一次吧^_^

使用macro无非两个用处:
1)、定义常量
2)、实现简单的函数功能

使用macro定义常量的缺点:
由于宏是由precompiler处理的,所以在真正的compiler处理之前就被precompiler移走了,没能进入符号表(symbol table),所以导致调试时的困难。

例:#define PI 3.1415926
我们可以用const double PI = 3.1415926替代。

还有一种class专有常数的例子:
class GamePlayer
{
    static const int NUM_TURNS = 5;
    int scores[NUM_TURNS];
    …
};
Const int GamePlayer::NUM_TURNS;

注意:in-class initialization只对整数类型(int ,bool,chars等)才成立且对常数才成立。

使用macro模拟函数功能的缺点:
例:
#define max(a , b) ((a) > (b)) ? (a): (b))
int a = 5 ,b =0;
max(++a , b); //我们期望是6>0,可实际结果是7>0,因为a被累加了两次。

我们的替代方法:inline int max(int a , int b){return a>b ? a : b ;}

Inline function可以对参数进行类型检查,而且拥有和macro一样的效率。

6、by reference vs by value
这里有几个原则(当然每个原则都不是强制性的,也都有特例),我们逐条来理解吧:

1)尽量使用by reference,不要使用by value,无论是传入参数还是传回返回值。
- reference通常不用于变量的修饰,多用于参数传递和返回值的修饰。
- by reference既有by pointer的效率,又保持接口与by value时不变,这些都是by reference的优点所在。
- 如果一定要by value,也不要钻牛角尖非得用by reference不可。(在下面一条的例子中就有体现)

2)不要在函式中传回local object的reference。(会造成dangling”空悬”问题)
Complex& func(…)
{
    Complex c;
    //…
    return c;
}

说明:这个函数会造成reference’s dangling problem,因为函数原型定义要传回reference,而代码却传回一个local object’s ref。

解决办法:修改返回值类型为by value

Complex func(…)
{
     Complex c;
     //…
     return c;
}

这样就一切ok了。(从函数代码的return c还看不出返回值是by value还是by reference, 得看函数原型的声明格式,如果是Complex&,则是by reference)。

7、const object与const member function
我们看一个类的成员函数的原型:

class Test
{
    //…
    const A& function(const B& b)const;
};

相信有很多人看完上述的函数原型后都有些“晕”,那么多const,都起什么作用亚。我们来一一讲解吧。

返回值类型:const A& —— 表示返回的值为常量,一般不能够作为左值,不能被修改(可作为右值)。
参数类型:const B& ——- 表示传入的参数b在函数的执行过程中状态应保持不变,不被修改。
函数后的修饰符const —— 表示该成员函数的执行不会改变类的状态,也就是说不会修改类的数据成员。

所谓的const object即在实例化时前面有const关键字修饰,如const A a;
所谓的const member function是指函数后有修饰符const,其通用的格式为:
    return_type fun_name(parameters list)const;

这样就会涉及到non-const object , const object 是否能够调用 non-const member function, const member function的问题,他们之间的调用关系详述如下:
- "const object" call "const member function" ok
- "non-const object" call "const member function" ok
- "const object" call "non-const member function" error
- "non-const object" call "non-const member function" ok
    
8、  friend & operator <<
有这样一段代码:

Complex c(3,5);
cout << c <<endl;

要想上一段代码编译和运行正常,我们应该做些什么呢?

在Complex类中重载<<符号,现在就有两种选择,是选择member func版还是non-member func版呢,根据上一段代码,cout << c,如果选择member func版,则书写方法应该是c<<cout,这明显不符合C++的习惯。所以我们选择non-member func版。版本选完后,我们来定这个重载函数的prototype, 由于cout为ostream类型且考虑到cout <<c<<c1这种级联形式,我们决定基本的原型如下:

osteam& operator<< (ostream& os , const Complex& r);

由于该函数需要访问Complex的private data member,所以我们再在前面加上一个friend关键字,完整的声明如下:

Class Complex
{
       //…
       friend osteam& operator<< (ostream& os , const Complex& r);
}

有了friend关键字表示Complex类告诉编译器operator<<函数是朋友,可以访问private data member。另外注意friend不具备传递性。

9、member func接受同型的obj,有无权利access private data member
我们还是看例子:

Complex& Complex::operator+=  (const Complex& x)
{
       m_real += x.m_real;
       m_imag+= x.m_imag;
       return *this;
}

从例子中看到member func接受同型的obj,是有权利access private data member的。

10、指针使用的好的编码习惯
当动态分配内存时,指针的使用应该格外的小心,一不注意就会造成memory leak。好的指针使用习惯会帮助你减少这种情况的发生。下面举例说明:

A p = new A();

当要free 这块内存时,应如下作法:

delete p;
p = null;//将p赋值为null以防止别人在free内存后,继续使用这个指针。

当使用p时,应如下作法:

if(p)//做一次判断,如果p不等于null,就可以使用了。
{
       p->dosomething();
}

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats