C++入门系列博客六 类

C++ 类


作者:AceTan,转载请标明出处!


0x00 面向对象与面向过程##

讨论类之前,我们有必要先探讨一下面向对象面向过程

  • 面向对象: Object Oriented,简称OO。面向对象是把构成问题的事物分解成各个对象,建立对象的目的不是为了完成一个个步骤,而是为了描述某个事物在解决整个问题的过程中所发生的行为。

  • 面向过程: Procedure Priented,简称PO。面向过程是分析解决问题的步骤,然后把这些步骤一步一步的实现,然后在使用的时候一一调用。

面向对象和面向过程代表了两种不同的编程思想。其本身并没有谁好谁坏之分,书本中其实有过分夸大面向对象作用之嫌。但是,在这个OO大行其道的年代,面向对象这种编程范式可能更符合实际需求,尤其是涉及GUI编程和庞大的系统规模时。总的来说,面向对象比面向过程多了一种对问题的抽象,它有时是一种更为有效的思路,打开了新世界的大门。

0x01 类

类(class) 是C++中提供的自定义数据类型的机制。类可以包含数据、函数和类型成员。一个类定义一种新的类型和一个新的作用域。我们用“类”来描述“对象”,而“对象”是指现实世界中的一切事物。类可以看做是对相似事物的抽象。

类的基本思想就是数据抽象(data abstraction)封装(encapsulation)。数据抽象是一种依赖于接口(interface)实现(implementation)分离的编程技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需要的各种私有函数。

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节。

类机制是C++最重要的特性之一。

有些人会混淆类和对象。简单来说,类和对象之间是抽象和具体的关系。类是对具有相同数据结构和相同操作的一类对象的描述。对象是描述其属性的数据和对这些数据的操作。


0x02 定义和使用类

C++使用class这个关键字来定义一个类。我们定义一个三角形类。

class Triangle
{
    // 公有方法
public:
    // 获取三角形周长
    double GetCircumference();
    // 获取三角形面积
    double GetArea();

    // 保护成员,只有其子类才能访问
protected:
    double a, b, c;        // 三角形的三个边长
};

以上的类进行了声明,并没有实现。

我们也可以用struct这个关键字来定义一个类。class关键字和struct关键字的唯一区别是:struct和class的默认访问权限不一样。class关键字定义的成员默认是private的,struct关键字定义的成员默认是public的(public和private下面会有介绍)。


0x03 面向对象的三个基本特征##

面向对象的三个基本特征是:封装、继承、多态。

  • 封装、继承、多态
  • 封装、继承、多态
  • 封装、继承、多态

听说重要的事情要说三遍。因为不仅考试要考,面试的笔试题也会经常见到。下面着重介绍一下这三个面向对象的基本特征。

封装

封装就是把客观的事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的信息隐藏。比如上面的三角形类,我们把具体的三角形抽象成一个三角形类,对外提供求周长和面积的方法(操作),我们对三角形的三条边长信息进行隐藏,只让继承它的子类访问。

C++中使用访问说明符(access specifiers) 来加强对类的封装。访问说明符具体有三个。

  • public : 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。

  • protected : 定义在protected说明符之后的成员在这个类以及其子类中可以访问。

  • private : 定义在private说明符之后的成员可以被类的成员函数访问,但不能被使用该类的代码访问,private封装了类的实现细节。

继承

在一个已存在的类的基础上建立一个新的类,新的类具有它所继承的类的全部特性,且可以增加一些新的特性。继承可以说是面向对象的程序设计最重要的特点。它实现了软件的可重用性(reuseability)

通过继承创建的新类称为“子类”或“派生类”。

被继承的类称为“基类”、“父类”或“超类”。

继承的过程,就是从一般到特殊的过程。

在C++语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区别对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。 例如,我们可以把上面的三角形类修改成这样。

class Triangle
{
    // 公有方法
public:
    // 获取三角形周长
    virtual double GetCircumference();
    // 获取三角形面积
    virtual double GetArea();

    // 保护方法,只有其子类才能访问
protected:
    double a, b, c;        // 三角形的三个边长
};

virtual 关键字提示了子类应该定义适合自身版本的操作。

派生类(子类)必须通过使用类派生列表(class derivation list)明确指出它是从哪个或者哪些基类继承而来的。

派生列表的形式是: 首先是一个冒号,后面紧跟着以逗号分隔的基类列表,其中每个基类前面可以有访问说明符(访问说明符即 public, protected, private。 如果省略这个访问说明符,那么默认的访问权限是什么呢?读者可自行测试一下,这也是新手经常遇到的坑)。

需要注意的是,C++支持多重继承,而很多其他语言并没有这一特性。比如Java中,一个子类有且仅有一个父类。多重继承有时候会让问题变的复杂,使用的时候要精心设计,倍加小心。

现在我们从上面普通的三角形类派生出一个子类,直角三角形类。

#include "Triangle.h"

// 直角三角形类
class RightTriangle : public Triangle
{
public :
    // 构造函数。直角三角形可以只初始化两个直角边
    RightTriangle(double a, double b);

    // 获取三角形周长
    virtual double GetCircumference();

    // 获取三角形面积
    virtual double GetArea();

private:
    
};

上面的直角三角形类共有继承了普通三角形类,可以使用普通三角形类的非私有成员。同时声明了自己版本的获取周长和面积的方法。

在考虑使用继承时,有一点是需要注意的,那就是两个类之间的关系应该是“属于”关系,也就是所谓的"is-a"关系。例如上面的,直角三角形是三角形。如果我们再定义一个等腰三角形,明显地,等腰三角形也可以继承三角形,因为它属于三角形。等腰三角形和直角三角形虽然都属于三角形,但这两个之间无法有继承和被继承的关系。

多态

对于OOP而言,多态性是指程序能通过引用或指针的动态类型(dynamic type)获取特定行为的能力。

动态类型: 对象在运行时的类型,引用所引对象或者指针所指对象的动态类型可能与该引用或者指针的静态类型不同。基类的指针或者引用可以指向一个派生类的对象。在这样的情况中,静态类型是基类的引用(或指针),而动态类型则是派生类的引用(或指针)。

接上面的例子,现在需要定义这样一个函数,它需要输出传入的三角形的周长和面积。

// 打印信息
void Triangle::PrintInfo(std::ostream& os, Triangle & triangle)
{

    if (!triangle.Judge())
    {
        os << "构不成三角形" << std::endl;
        return;
    }

    os << "该三角形的面积为:" << triangle.GetArea() << ",周长为" << triangle.GetCircumference() << std::endl;

}

上面的代码中,形参为引用类型。代码中调用了GetArea()和GetCircumference()方法,而这两个方法都是虚函数。如果传入的参数是普通的三角形(Triangle类),那么它就会调用普通三角形求周长和面积的方法。如果我们传入的是直角三角形,那么它就调用的是直角三角形求周长和面积的方法。具体调用哪个方法,只有程序运行的时候才能确定。这就是所谓的多态性。

多态性一般可以分为编译时多态和运行时多态。函数重载和模板都属于编译时多态(因为他们没有虚表,且使用时需要指定类型)。虚函数是运行时多态。

OOP的核心思想是多态性(polymorphism)。指针或引用的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。

为了更好的理解多态性,我举一个生活中的例子。我们日常生活中说的“打球”,这个“打”就表示了一种抽象的信息,具有很多种含义。我们可以说,打乒乓球,打篮球,打羽毛球,都使用“打”来表示某种球类运动。这实际上就是对运动行为的一个抽象。运行时可以确定是打什么球。比如调用者是姚明,我们就能确定是打篮球,调用者是大魔王张怡宁,我们就能确定是打乒乓球。

纯虚函数####

谈到虚函数,我们就不能对纯虚(pure virtual)函数避而不谈。纯虚函数就是在虚函数声明后书写 =0,这样一个虚函数就变成了纯虚函数。

值得注意的一点是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。

含有纯虚函数的类都是抽象基类。

抽象基类负责定义接口。 如果你熟悉Java或者C#中如何定义接口,你将有一种熟悉的感觉。

我们不能创建抽象基类的对象。 换句话说,抽象基类是不能实例化的。赶紧画个重点,考试必考哈。

有些人可能已经蒙圈了。啥是抽象基类,啥是接口?不一样么? 这里简单地讲一下他们的区别。

  • 抽象类: 它是特殊的类,只是不能被实例化(将定义了纯虚函数的类称为抽象类);除此以外,具有类的其他特性;重要的是抽象类可以包括抽象方法,这是普通类所不能的,但同时也能包括普通的方法。 C#和Java中用关键字Abstract定义的。

  • 接口: 接口是一个概念。它在C++中用抽象类来实现,在C#和Java中用interface来实现。

我们现在改写一下我们的三角形类,给它抽象出个接口出来。

ITriangle.h文件:

#pragma once

// 三角形的接口。这个一个虚基类,没有其他普通函数,也没有类成员,只声明了两个虚函数。
class ITriangle
{

public:
    // 获取三角形周长
    virtual double GetCircumference() = 0;        // = 0,表明它是虚函数

    // 获取三角形面积
    virtual double GetArea() = 0;

};

Triangle.h文件:

#pragma once

#include <ostream>
#include "ITriangle.h"

// 三角形类,继承三角形的接口
class Triangle : ITriangle
{
    // 共有方法
public:
    // 构造函数
    Triangle(double _a, double _b, double _c);
    // 默认构造函数
    Triangle() = default;

    // 判断三角形是否合法
    bool Judge();
    // 获取三角形周长, override关键字表明覆盖基类中的函数版本。
    double GetCircumference() override;
    // 获取三角形面积
    double GetArea() override;
    // 打印信息
    void PrintInfo(std::ostream& os, Triangle& triangle);

    // 保护方法,只有其子类才能访问
protected:
    double a, b, c;        // 三角形的三个边长
};

0x04 友元##

先说一下为什么会有友元(friend)这玩意。举个生活中的例子,老王的儿子小明生病了,带他去看医生,恰巧老王的大表哥老宋是这家医院的院长,在中国这个关系社会,老王多半会直接找到老宋,开个后门,直接快速就医,免去了一下繁琐的排队流程。C++中友元干的事和这个差不多,在实现类之间数据共享时,减少系统开销,提高效率。

类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元。如果类想把一个函数作为它的友元,只需增加一条以friend关键字开始的函数声明即可。

友元很明显的一个缺点就是它破坏了封装机制,除非不得已的情况,一般不使用友元函数。友元一般用在如下两种情况:

  1. 运算符重载的某些场合
  2. 两个类要共享数据

友元函数的位置###

友元函数是类外的函数,所以它的声明可以放在类的私有段或者公有段,且这没什么分别。

友元函数的调用###

可以直接调用友元函数,不需要通过对象或指针。

友元函数和类的成员函数的区别###

  • 成员函数有this指针,而友元函数没有this指针。

  • 友元关系不存在传递性。友元函数是不能被继承的,就像父亲的朋友未必是儿子的朋友。

友元的简单示例###

《C++ Prime》中一个很好的例子:有一个Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据。假设需要为Window_mgr添加一个加为clear的成员,它负责把一个指定的Screen的内容都设为空白。为了完成这一个任务,clear需要访问Screen的私有成员;而要想令这种访问合法,Screen需要把Window_mgr指定成它的友元。具体的代码如下:

#include <vector>
#include <string>

using namespace std;

class Screen
{
    // Window_mgr的成员的访问Screen类的私有部分
    friend class Window_mgr;
    // Screen类的剩余部分

private:
    string contents;
    int height;
    int width;

};

class Window_mgr
{
public:
    // 窗口中每个屏幕的编号
    using ScreenIndex = vector<Screen>::size_type;
    // 按照编号将指定的Screen重置为空白
    void clear(ScreenIndex);

private:
    vector<Screen> screens;
};

void Window_mgr::clear(ScreenIndex i)
{
    // s是一个Screen的引用,指向我们想要清空的那个屏幕
    Screen& s = screens[i];
    // 将那个选定的Screen重置为空白
    s.contents = string(s.height * s.width, ' ');
}

0x05 final和override说明符

有时我们会遇到这样一种情况,派生类如果定义了一个函数与基类中的虚函数的名字相同但形参列表不同,这仍然是一个合法的行为。编译器将认为新定义的这个函数和基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。然后蛋疼的问题就发生了,我原来想覆盖掉基类中的虚函数,可一不小心把形参给弄错了。这种bug其实很难找。好在C++11新标准中为我们提供了一个override关键字来说明派生类中的虚函数。如果我们使用override标记了某个函数,但该函数没有覆盖已存在的虚函数,此时编译器将报错。 然后我们根据编译器的报错,就能迅速定位问题代码,解决问题。

struct B
{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};

struct D1 : B
{
    void f1(int) const override;    // 正确:f1与基类中f1的版本匹配
    void f2(int) override;            // 错误:B中没有形如f2(int)的函数
    void f3() override;                // 错误:f3不是虚函数
    void f4() override;                // 错误:B中没有名为f4的函数
};

还有一种情况,我们想要一个函数不能被覆盖。这时候我们可以把这个函数指定为final,如果我们把函数定义成final了,那么任何尝试覆盖该函数的操作都讲引发错误。

struct D2 : B
{
    // 从B继承f2()和f3(),覆盖f1(int)
    void f1(int) const final;        // 不允许后续的其他类覆盖f1(int)
};

struct D3 : B
{
    void f2();                        // 正确:覆盖从间接基类B中继承而来的f2
    void f1(int) const;                // 错误:D2已经将f2声明成final
};

要注意一点的就是,这两个说明符在C++11新标准新可以使用。Java和C#中有类似的关键字,C++11新标准应该是借鉴了他们的做法。


0x06 五种特殊的的成员函数

当定义一个类时,我们显式地或者隐式地指定在此类型的对象拷贝、移动和销毁时做什么。这些操作是必须的,你也许会疑问,之前我们定义的类不是没有这些操作么?那是因为万能的编译器自动定义了缺失的操作,实际上这就导致了一个问题,编译器补充定义的不是我想要的怎么办?这种问题非常常见,尤其是在你想拷贝一个类的时候。

一个类通过定义五种特殊的成员函数来控制拷贝,移动,赋值和销毁,他们是:

  • 拷贝构造函数(copy constructor)

  • 拷贝赋值运算符(copy-assignment operator)

  • 移动构造函数(move constructor)

  • 移动赋值运算符(move-assignment operator)

  • 析构函数(destructor)

移动操作是新标准引入的操作,我们先来讨论一下拷贝构造函数、拷贝赋值函数和析构函数。

拷贝构造函数

如果一个参数的第一个参数是自身类类型的引用且任何额外参数都有默认值(一般没有很少加额外参数),此构造函数是拷贝构造函数。

class Foo
{
public:
    Foo();                // 默认的构造函数
    Foo(const Foo&);    // 拷贝构造函数

    // 其他
};

拷贝构造函数的第一个参数必须是一个引用类型,且此参数几乎总是一个const的引用。

如果我们没有为类定义一个拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。

只有当类没有任何构造函数时,编译器才会自动地生成默认构造函数。

合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。

每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数类拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素的类型是类类型,则使用元素的拷贝构造函数来进行拷贝。

直接初始化和拷贝初始化的差异:当使用直接初始化时,我们实际上要求编译器使用普通的函数匹配来选择我们与我们提供的参数做匹配的构造函数。当我们使用拷贝初始化(copy initialization),我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

string dots(10, '.');            // 直接初始化
string s(dots);                    // 直接初始化
string s2 = dots;                // 拷贝初始化
string book = "8-8888-888-8";    // 拷贝初始化
string lines = string(100, '8');// 拷贝初始化

拷贝初始化通常使用拷贝构造函数来完成,但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成 。

拷贝赋值运算符

当我们重载了赋值运算符(=)后,我们就可以使用=直接给类赋值。同样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个。

重载运算符(overloaded operator) 本质上是一个函数,其名字由operator关键字后接表示要定义的运算符的符号组成(更详细的内容以后会做介绍,现在只需要了解这个即可)。赋值运算符就是一个名为 operator= 的函数

重载运算符的参数表示运算符的运算对象。 某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就是一个绑定到隐式的this参数。对于一个二元运算符,例如这里的赋值运算符,其右侧运算对象作为显式参数传递。

class Foo
{
public:
    Foo& operator=(const Foo&);        // 赋值运算符

    // 其他
};

赋值运算符通常应该返回一个指向其左侧运算对象的引用。

析构函数

析构函数执行与构造函数相反的操作:析构函数初始化对象的非static数据成员,还可以做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。

析构函数是类的一个成员函数,名字由波浪号(~)接类名构成,没有返回值,也不接受参数。

class Foo
{
public:
    ~Foo();        // 析构函数

    // 其他
};

析构函数完成的工作: 如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照他们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。

什么时候会调用析构函数

  • 变量在离开其作用域的时被销毁

  • 当一个对象被销毁时,其成员被销毁。

  • 容器(无论是标准容器库还是数组)被销毁时,其元素被销毁。

  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。

  • 对于临时对象,当创建它的完整表达式结束时被销毁。

上述一个比较全面的例子:

#include<iostream>
using namespace std;

// 日期类
class Date
{        
public:
    Date(int y = 1970, int m = 1, int d = 1);        // 构造函数
    Date(const Date &date);                            // 拷贝构造函数
    Date& operator=(const Date&);                    // 拷贝赋值运算符
    ~Date();                                        // 析构函数
    void Print();                                    // 打印信息

private:
    int year, month, day;        // 成员:年、月、日
};

// 构造函数,使用初始化列表初始化类的成员
Date::Date(int y, int m, int d) :year(y), month(m), day(d)
{
    cout << "Constructor called." << endl;
}

// 拷贝构造函数
Date::Date(const Date &date)
{
    year = date.year;
    month = date.month;
    day = date.day;

    cout << "Copy Constructor called." << endl;
}

// 拷贝赋值运算符
Date& Date::operator=(const Date& rDate)
{
    year = rDate.year;
    month = rDate.month;
    day = rDate.day;

    cout << "Copy-assignment Operator called" << endl;

    return *this;
}

// 打印信息
void Date::Print()
{
    cout << year << "年" << month << "月" << day << "日" << endl;
}

// 析构函数
Date::~Date()
{
    cout << "Destructor called.\n";
}

int main()
{
    Date day1(2016, 8, 21);            // 调用构造函数直接初始化
    Date day2;
    Date day3(day1);                // 拷贝初始化
    Date day4 = day3;                
    day2 = day4;

    day2.Print();

    return 0;
}

输出结果是:

Constructor called.                // day1调用了构造函数
Constructor called.                // day2调用了构造函数
Copy Constructor called.        // day3拷贝构造函数
Copy Constructor called.        // day4拷贝构造函数
Copy-assignment Operator called // day4赋值运算符重载
2016年8月21日                    // day2的信息
Destructor called.                // day4被销毁
Destructor called.                // day3被销毁
Destructor called.                // day2被销毁
Destructor called.                // day1被销毁

移动构造函数和移动赋值构造函数

这是新标准新加的两个操作,为什么会添加这样的操作呢? 因为在我们使用类时,很多情况下都要发生对象拷贝,而其中某些情况下,对象拷贝后就立即被销毁了,在这些情况下,移动而非拷贝对象就会大幅度提升性能。 有点雕版印刷术和活版印刷术的意思。

为了支持移动操作,新标准引入了一种新的引用类型— 右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。我们通过&&而非&来获得右值引用。右值引用有一个重要性质一只能绑定到一个将要销毁的对象。 因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

与之相对的还有一个叫左值引用(lvalue reference)。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。

返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值表达式的例子。我们可以将一个左值引用绑定到这个类的表达式的结果上。

返回引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。

我们要记住以下两点:

  • 左值持久,右值短暂。

  • 变量是左值。

接上面的例子,来看一下如何定义一个移动构造函数和移动赋值构造函数

// 函数声明
Date (Date&& date) noexcept;                    // 移动构造函数
Date& operator=(Date&&) noexcept;                // 移动赋值运算符

// 移动构造函数
Date::Date(Date &&date)  noexcept :                    // 移动操作不应该抛出异常
    year(date.year), month(date.month), day(date.day)
{
    year = month = day = 0;
    cout << "Move Constructor called" << endl;
}

// 移动赋值运算符
Date& Date::operator=(Date&& rDate) noexcept        // 移动操作不应该抛出异常
{
    // 直接检测自赋值
    if (this != &rDate)
    {
        year = rDate.year;
        month = rDate.month;
        day = rDate.day;

        // 将rDate置于可析构状态
        rDate.year = rDate.month = rDate.day = 0;
    }

    cout << "Move-assignment Operator called" << endl;

    return *this;
}

// 其他见上面

0x07 一些法则

上面提到的五种特殊的成员函数,实际上统称为拷贝控制。以下的一些法则是实践中的一些经验积累,我们应当遵循这些法则,这样能少犯一些错误。

  • 需要析构函数的类也需要拷贝和赋值操作

  • 需要拷贝操作的类也需要赋值操作,反之亦然。

  • 实际上,新标准中加入了移动操作后,如果一个类定义了任何一个拷贝操作,它就应该定义所有的五个操作。

0x08 阻止拷贝

既然可以进行拷贝,那么也有阻止拷贝的机制。

在新标准之前,一般的做法是把拷贝控制函数放在private访问说明符下,这样就可以阻止拷贝啦。

新标准简单粗暴,直接把这些函数定义为删除的函数(deleted function)。具体做法就是在函数的参数列表后面加上=delete。如下:

Date(const Date &date) = delete;                // 拷贝构造函数

0x08 结束语

面向对象编程(OOP)是一种思想的转变,需要勤加练习,这样才能掌握C++中的类。希望大家能面向对象编程,也能真正地面向对象编程(滑稽脸)。

真正地面向对象编程

推荐阅读更多精彩内容

  • 前言 把《C++ Primer》[https://book.douban.com/subject/25708312...
    尤汐Yogy阅读 7,817评论 1 51
  • C++文件 例:从文件income. in中读入收入直到文件结束,并将收入和税金输出到文件tax. out。 检查...
    SeanC52111阅读 1,647评论 0 3
  • 前言 人生苦多,快来 Kotlin ,快速学习Kotlin! 什么是Kotlin? Kotlin 是种静态类型编程...
    橘之缘之空阅读 23,320评论 9 118
  • 时间似乎又过去了一大半,翻开日历,今天已经立秋,如期而至的一场雨淋透了所有的炙热,仿佛这个夏已经被掩埋至过去。 打...
    樱花正开阅读 73评论 2 2
  • 回不去的家乡,到不了的远方。一点一滴都是儿时最珍贵的回忆。 日落黄昏,一个人的故事一个人写完,我思念的人是否会乘着...
    我叫O泡阅读 1,362评论 19 32