什么是复制和交换习语?

GMa*_*ckG 1907 c++ c++-faq copy-constructor assignment-operator copy-and-swap

这个成语是什么,什么时候应该使用?它解决了哪些问题?当使用C++ 11时,成语是否会改变?

虽然在许多地方已经提到过,但我们没有任何单一的"它是什么"问题和答案,所以在这里.以下是前面提到的地方的部分列表:

GMa*_*ckG 2088

概观

为什么我们需要复制和交换习惯用法?

任何管理资源的类(包装器,如智能指针)都需要实现三巨头.虽然复制构造函数和析构函数的目标和实现很简单,但复制赋值运算符可以说是最微妙和最困难的.应该怎么做?需要避免哪些陷阱?

复制和交换成语是解决方案,并协助典雅赋值运算符在实现两件事情:避免重复代码,并提供了一个强大的异常保证.

它是如何工作的?

从概念上讲,它通过使用复制构造函数的功能来创建数据的本地副本,然后使用swap函数获取复制的数据,使用新数据交换旧数据.然后临时拷贝破坏,用它来获取旧数据.我们留下了新数据的副本.

为了使用复制和交换习惯用法,我们需要三件事:一个工作的复制构造函数,一个工作的析构函数(两者都是任何包装器的基础,所以应该是完整的),以及一个swap函数.

交换函数是一种非抛出函数,它交换类的两个对象,成员的成员.我们可能会试图使用std::swap而不是提供我们自己的,但这是不可能的; std::swap在其实现中使用copy-constructor和copy-assignment运算符,我们最终会尝试根据自身定义赋值运算符!

(不仅如此,但是不合格的调用swap将使用我们的自定义交换运算符,跳过不必要的构造和对我们的类的破坏std::swap.)


深入解释

目标

让我们考虑一个具体案例.我们想在一个无用的类中管理一个动态数组.我们从一个工作构造函数,复制构造函数和析构函数开始:

#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
    // (default) constructor
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // copy-constructor
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        // note that this is non-throwing, because of the data
        // types being used; more attention to detail with regards
        // to exceptions must be given in a more general case, however
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // destructor
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};
Run Code Online (Sandbox Code Playgroud)

这个类几乎成功地管理了数组,但它需要operator=正常工作.

失败的解决方案

这是一个天真的实现可能看起来如何:

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // get rid of the old data...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) *(see footnote for rationale)

        // ...and put in the new
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}
Run Code Online (Sandbox Code Playgroud)

我们说我们已经完成了; 这现在管理一个数组,没有泄漏.但是,它遇到了三个问题,在代码中依次标记为(n).

  1. 首先是自我指派测试.这个检查有两个目的:它是一种简单的方法来阻止我们在自我分配上运行不必要的代码,它可以保护我们免受微妙的错误(例如删除数组只是为了尝试复制它).但在所有其他情况下,它只会减慢程序的速度,并在代码中充当噪声; 自我指派很少发生,因此大多数时候这种检查是浪费.如果没有它,操作员可以正常工作会更好.

  2. 第二是它只提供基本的例外保证.如果new int[mSize]失败,*this将被修改.(即,大小错误,数据不见了!)对于强大的异常保证,它需要类似于:

    dumb_array& operator=(const dumb_array& other)
    {
        if (this != &other) // (1)
        {
            // get the new data ready before we replace the old
            std::size_t newSize = other.mSize;
            int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
            std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
            // replace the old data (all are non-throwing)
            delete [] mArray;
            mSize = newSize;
            mArray = newArray;
        }
    
        return *this;
    }
    
    Run Code Online (Sandbox Code Playgroud)
  3. 代码已经扩展了!这引出了第三个问题:代码重复.我们的赋值运算符有效地复制了我们已经在其他地方写过的所有代码,这是一件非常糟糕的事情.

在我们的例子中,它的核心只有两行(分配和副本),但是由于资源更复杂,这个代码膨胀可能会非常麻烦.我们应该努力永不重复.

(有人可能会想:如果需要这么多代码来正确管理一个资源,那么如果我的班级管理不止一个怎么办?虽然这似乎是一个有效的问题,而且它确实需要非平凡try/ catch从句,这是一个非-issue.那是因为一个班级应该管理一个资源!)

成功的解决方案

如上所述,复制和交换习惯用法将解决所有这些问题.但是现在,我们有除了一个以外的所有要求:一个swap功能.虽然The Rule of Three成功地需要我们的拷贝构造函数,赋值运算符和析构函数的存在,但它应该被称为"三巨头半":任何时候你的类管理资源,提供一个swap函数也是有意义的.

我们需要在我们的类中添加交换功能,我们这样做如下†:

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // enable ADL (not necessary in our case, but good practice)
        using std::swap;

        // by swapping the members of two objects,
        // the two objects are effectively swapped
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};
Run Code Online (Sandbox Code Playgroud)

(是解释原因public friend swap.)现在我们不仅可以交换我们dumb_array的,而且一般来说交换可以更有效率; 它只是交换指针和大小,而不是分配和复制整个数组.除了功能和效率方面的这一奖励外,我们现在已准备好实施复制和交换习惯用法.

不用多说,我们的赋值运算符是:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}
Run Code Online (Sandbox Code Playgroud)

就是这样!一举一动,所有三个问题都得到了优雅的解决.

它为什么有效?

我们首先注意到一个重要的选择:参数参数是按值进行的.虽然人们可以轻松地执行以下操作(事实上,许多简单的习惯实现):

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}
Run Code Online (Sandbox Code Playgroud)

我们失去了重要的优化机会.不仅如此,这种选择在C++ 11中至关重要,后面将对此进行讨论.(一般来说,一个非常有用的指导如下:如果你要在函数中复制一些东西,让编译器在参数列表中执行.‡)

无论哪种方式,这种获取资源的方法是消除代码重复的关键:我们可以使用copy-constructor中的代码来制作副本,而不需要重复任何一点.现在副本已经完成,我们已准备好进行交换.

注意,在进入该功能时,已经分配,​​复制并准备好使用所有新数据.这就是免费提供强有力的异常保证:如果复制的构造失败,我们甚至不会进入函数,因此不可能改变状态*this.(我们之前手动完成了强大的异常保证,编译器现在正在为我们做;多么善良.)

在这一点上,我们没有家,因为swap是非投掷.我们将当前数据与复制的数据交换,安全地改变我们的状态,并将旧数据放入临时数据中.然后在函数返回时释放旧数据.(在参数的作用域结束并调用其析构函数的位置.)

因为习惯用法不重复代码,所以我们不能在运算符中引入错误.请注意,这意味着我们不需要进行自我分配检查,只需要统一实现operator=.(此外,我们不再对非自我分配造成性能损失.)

这就是复制和交换的习惯用语.

那么C++ 11呢?

C++的下一个版本,C++ 11,对我们管理资源的方式做了一个非常重要的改变:三个规则现在是四个规则(一半).为什么?因为我们不仅需要能够复制构建我们的资源,我们还需要移动构建它.

幸运的是,这很容易:

class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other)
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};
Run Code Online (Sandbox Code Playgroud)

这里发生了什么?回想一下移动构造的目标:从类的另一个实例中获取资源,使其处于保证可分配和可破坏的状态.

所以我们所做的很简单:通过默认构造函数初始化(C++ 11特性),然后交换other; 我们知道我们类的默认构造实例可以安全地分配和销毁,所以我们知道other在交换后也可以这样做.

(请注意,有些编译器不支持构造函数委托;在这种情况下,我们必须手动默认构造类.这是一个不幸但很幸运的简单任务.)

为什么这样做?

这是我们需要对我们班级做出的唯一改变,那为什么它会起作用?请记住我们为使参数成为值而非参考而做出的非常重要的决定:

dumb_array& operator=(dumb_array other); // (1)
Run Code Online (Sandbox Code Playgroud)

现在,如果other用rvalue初始化,它将被移动构造.完善.以同样的方式,C++ 03让我们通过使用参数by-value重用我们的拷贝构造函数,C++ 11也会在适当的时候自动选择move-constructor.(当然,正如先前链接的文章中所提到的,可以简单地完全删除复制/移动值.)

因此,复制和交换习语就此结束.


脚注

*为什么我们设置mArray为null?因为如果运算符中的任何其他代码抛出,则dumb_array可能会调用析构函数; 如果发生这种情况而没有将其设置为null,我们会尝试删除已经删除的内存!我们通过将其设置为null来避免这种情况,因为删除null是一种无操作.

†还有其他声称,我们应该专门std::swap为我们的类型,提供一个类内swap的自由功能swap,等等.但这是不必要的:任何正确使用swap将通过一个不合格的电话,我们的功能将是通过ADL找到.一个功能就可以了.

‡原因很简单:一旦你拥有了自己的资源,你可以在任何需要的地方交换和/或移动它(C++ 11).通过在参数列表中创建副本,可以最大化优化.

  • 我不明白为什么交换方法在这里被宣布为朋友? (20认同)
  • @GMan:我认为一个管理多个资源的类注定要失败(异常安全变得噩梦),我强烈建议一个类管理一个资源,或者它有业务功能并使用管理器. (15认同)
  • @neuviemeporte:你需要在ADL期间找到你的`swap`,如果你想让它在大多数你会遇到的通用代码中工作,比如`boost :: swap`和其他各种交换实例.交换在C++中是一个棘手的问题,通常我们都同意单点访问是最好的(为了一致性),一般来说,唯一的方法就是自由函数(`int`不能有交换成员,例如).有些背景,请参阅[我的问题](http://stackoverflow.com/questions/9170247/does-c11-change-the-behavior-of-explicitly-calling-stdswap-to-ensure-adl-loc). (8认同)
  • @neuviemeporte:使用括号,数组元素默认初始化.没有,他们是未初始化的.因为在复制构造函数中我们无论如何都会覆盖这些值,所以我们可以跳过初始化. (7认同)
  • @asd:允许通过ADL找到它. (6认同)
  • @BenHymers:是的.复制和交换习惯用于仅以一般方式简化新资源管理类的创建.对于每个特定的班级,几乎可以肯定有一条更有效的路线.这个成语只是有效的东西,很难做错. (5认同)
  • @James:不,你是对的,你可以.你只需要在C++ 03和C++ 0x中取值.(在C++ 03中,lvalues被复制,rvalues希望得到它们的副本被省略,在C++ 0x中lvalues被复制,ravlues被移动,有时候有希望被省略). (4认同)
  • @GMan:或者你使用C++ 0x`nothrow`限定符:-) (3认同)
  • @GMan:重读这篇文章(C++ 0x部分),我想知道*Big Four*.我会说*Big Five*包括*Move Assignment Operator*:如果你没有申报,那么P是否会自动定义*移动分配操作员? (3认同)
  • @neuviemeporte:如果你想在ADL期间找到它(`使用std :: swap; swap(x,y);`),它需要成为全球朋友. (3认同)
  • 如果`dumb_array :: dumb_array()`很昂贵(比方说,它总是保留一些空间),关于`dumb_array(dumb_array && other)`(即默认构造然后交换)的建议会不同吗?在这种情况下,通过从另一个对象移动来初始化成员,然后将移出的对象的状态归零,会更好吗? (3认同)
  • @VisViva类中的朋友是周围范围内的非成员函数,但作为朋友,它通常可以访问非公共成员.这是一种风格选择,但可以非常简洁地定义那里的小函数,而不是声明它们并且必须在启动到函数体之前在周围范围内重复签名(可能使用更明确的模板参数). (3认同)
  • 通过复制构造函数实现赋值可能会导致不必要的分配,甚至可能导致无内存错误.考虑在具有<2GB堆限制的机器上将700MB"dumb_array"分配给1GB"dumb_aaray"的情况.最佳分配将意识到它已经分配了足够的内存,并且只将数据复制到已经分配的缓冲区中.在释放1GB缓冲区之前,您的实现将导致另外700MB缓冲区的分配,导致所有3个缓冲区同时尝试在内存中共存,这将不必要地抛出内存不足错误. (3认同)
  • @GManNickG你为什么不把它变成一个带有移动赋值算子的"五大"?或者,赋值运算符的给定实现是否不再需要它?从评论和文本中都不清楚,如果你能加入一个关于它的段落那就太棒了!谢谢 :) (3认同)
  • 非常好的总结!我个人会注释掉`throw()`.将文本留在那里表示你不认为该函数会抛出,但不要留下可能的惩罚:http://www.boost.org/development/requirements.html#Exception-specification (2认同)
  • 四大?(1) dtor,(2) 复制ctor,(3) 复制op=,(4) 移动ctor,以及(5) 移动op=。其中哪些没有被计算在内? (2认同)
  • @Gabriel:啊,的确如此.也就是说,我从来没有找到一个明确的理由来使复制构造函数显式化. (2认同)
  • @GMan:我不确定我是否理解;不会让 swap() 成为成员使其更简单,这样您就根本不必关心 ADL 了吗? (2认同)
  • @zmb:"全局"部分(即完全在类外声明)是可选的.重要的是它是非成员友元函数,因此它将在ADL期间找到,例如当标准库调用`swap(x,y);`时.有关更完整的信息,请参阅[this](http://stackoverflow.com/questions/5695548/public-friend-swap-member-function). (2认同)
  • 为什么这个交换函数在这行'朋友'声明为`friend void swap(dumb_array&first,dumb_array&second)// nothrow`?如果它在一个类中声明不是它只是一个成员函数? (2认同)
  • @GManNickG:由于它牺牲了可维护性的效率,你应该在问题或答案中明确指出复制和交换是****解决方案,而不是**解决方案,并且可能有意义的是重用对象而不是构造一个新对象.否则人们就不知道了.(这似乎很明显,但事实并非如此;我花了很长时间才意识到这一点.) (2认同)
  • @ Luv2code是的,你理解这个成语:) (2认同)

sbi*_*sbi 262

分配的核心是两个步骤:拆除对象的旧状态,并将其新状态构建为其他对象状态的副本.

基本上,这就是析构函数复制构造函数的作用,因此第一个想法是将工作委托给它们.然而,由于破坏必定不会失败,而建筑可能,我们实际上想要反过来做:首先执行建设性部分,如果成功,那么做破坏性部分.复制和交换习惯用法就是这样做的:它首先调用类的复制构造函数来创建临时文件,然后用临时文件交换数据,然后让临时的析构函数破坏旧状态.
以来swap()应该永远不会失败,唯一可能失败的部分是复制结构.首先执行此操作,如果失败,则目标对象中不会更改任何内容.

在其精炼形式中,通过初始化赋值运算符的(非引用)参数来执行复制来实现复制和交换:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}
Run Code Online (Sandbox Code Playgroud)

  • @wilhelmtell:在C++ 03中,没有提到`std :: string :: swap`(由`std :: swap`调用)可能引发的异常.在C++ 0x中,`std :: string :: swap`是`noexcept`,不能抛出异常. (11认同)
  • `std :: swap(this_string,that)`不提供无抛出保证.它提供强大的异常安全性,但不是无抛出保证. (7认同)
  • @sbi @JamesMcNellis好的,但是重点仍然存在:如果您有类类型的成员,则必须确保交换它们是没有问题的。如果您只有一个成员是指针,那么这很简单。否则不是。 (2认同)
  • @wilhelmtell:我认为那是交换点:它永远不会抛出,它总是O(1)(是的,我知道,`std :: array` ...) (2认同)

Ton*_*roy 41

已经有一些好的答案了.我将主要关注我认为他们缺乏的东西 - 用复制和交换习语解释"缺点"......

什么是复制和交换习语?

一种根据交换函数实现赋值运算符的方法:

X& operator=(X rhs)
{
    swap(rhs);
    return *this;
}
Run Code Online (Sandbox Code Playgroud)

基本思想是:

  • 分配给对象最容易出错的部分是确保获取新状态所需的任何资源(例如内存,描述符)

  • 修改对象的当前状态之前可以尝试获取(即*this)如果创建了新值的副本,这就是为什么rhs接受(即复制)而不是通过引用

  • 交换本地副本的状态rhs*this通常比较容易做到无潜在故障/异常,考虑到本地副本不需要任何特殊的状态之后(只需要状态适合析构函数运行,就像一个对象正在移动 from in> = C++ 11)

什么时候应该使用?(它解决了哪些问题[/ create]?)

  • 如果您希望被分配的对象不受引发异常的赋值的影响,假设您已经或可以编写swap具有强异常保证的分配,并且理想情况下是一个不能失败的分配/ throw..†

  • 当你想要一个干净,易于理解,健壮的方法来定义赋值运算符(简单)复制构造swap函数和析构函数.

    • 作为复制和交换完成的自我分配避免了被忽视的边缘情况.‡

  • 在分配期间通过拥有额外临时对象而产生的任何性能损失或暂时更高的资源使用对您的应用程序而言并不重要.⁂

swap投掷:通常可以通过指针可靠地交换对象跟踪的数据成员,但是没有无抛出交换的非指针数据成员,或者必须实现交换X tmp = lhs; lhs = rhs; rhs = tmp;和复制构造或赋值的非指针数据成员可能会抛出,仍然有可能失败,留下一些数据成员交换而其他人没有.这个潜力甚至适用于C++ 03 std::string,因为詹姆斯评论了另一个答案:

@wilhelmtell:在C++ 03中,没有提到std :: string :: swap(由std :: swap调用)可能抛出的异常.在C++ 0x中,std :: string :: swap是noexcept,不能抛出异常. - 詹姆斯麦克尼利斯2010年12月22日15:24


‡当从不同的对象分配时,分配运算符实现看起来很明智,很容易因自我分配而失败.虽然客户端代码甚至可能尝试自我分配似乎是不可想象的,但是在容器上的算法操作期间它可以相对容易地发生,x = f(x);代码在哪里f(可能仅针对某些#ifdef分支)宏ala #define f(x) x或返回引用的函数x,甚至(可能是低效但简洁的)代码x = c1 ? x * 2 : c2 ? x / 2 : x;).例如:

struct X
{
    T* p_;
    size_t size_;
    X& operator=(const X& rhs)
    {
        delete[] p_;  // OUCH!
        p_ = new T[size_ = rhs.size_];
        std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
    }
    ...
};
Run Code Online (Sandbox Code Playgroud)

在自我分配时,上面的代码delete x.p_;,指向p_一个新分配的堆区域,然后尝试读取其中未初始化的数据(未定义的行为),如果它没有做任何太奇怪的事情,copy尝试自我分配给每一个 -破坏'T'!


⁂由于使用额外的临时(当运算符的参数是复制构造时),复制和交换习惯用法会引入效率低下或限制:

struct Client
{
    IP_Address ip_address_;
    int socket_;
    X(const X& rhs)
      : ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
    { }
};
Run Code Online (Sandbox Code Playgroud)

在这里,手写Client::operator=可能会检查是否*this已连接到同一服务器rhs(如果有用,可能会发送"重置"代码),而复制和交换方法将调用可能被写入打开的复制构造函数一个独特的套接字连接然后关闭原来的.这不仅意味着远程网络交互而不是简单的进程内变量复制,它可能会对套接字资源或连接的客户端或服务器限制产生影响.(当然这个类有一个非常可怕的界面,但那是另一回事;-P).

  • 也就是说,套接字只是一个例子 - 同样的原则适用于任何可能昂贵的初始化,例如硬件探测/初始化/校准,生成线程池或随机数,某些加密任务,高速缓存,文件系统扫描,数据库连接等.. (4认同)
  • “客户端”的副本分配运算符的主要问题是不禁止分配。 (3认同)

Ole*_*siy 22

这个答案更像是对上述答案的补充和略微修改.

在某些版本的Visual Studio(以及可能的其他编译器)中,有一个非常烦人且没有意义的错误.所以如果你声明/定义你的swap函数是这样的:

friend void swap(A& first, A& second) {

    std::swap(first.size, second.size);
    std::swap(first.arr, second.arr);

}
Run Code Online (Sandbox Code Playgroud)

...当你调用swap函数时,编译器会对你大喊:

在此输入图像描述

这与friend被调用的函数和this作为参数传递的对象有关.


解决这个问题的方法是不使用friend关键字并重新定义swap函数:

void swap(A& other) {

    std::swap(size, other.size);
    std::swap(arr, other.arr);

}
Run Code Online (Sandbox Code Playgroud)

这一次,您可以调用swap并传入other,从而使编译器满意:

在此输入图像描述


毕竟,您不需要使用friend函数来交换2个对象.使swap一个other对象作为参数的成员函数同样有意义.

您已经可以访问this对象,因此将其作为参数传递在技术上是多余的.

  • 请注意,这只是IDE代码突出显示(IntelliSense)中的一个错误...它将编译得很好,没有警告/错误. (14认同)
  • @GManNickG它不适合所有图像和代码示例的注释.如果有人投票,那就没问题了,我确信那里有人会得到同样的错误; 这篇文章中的信息可能正是他们所需要的. (8认同)
  • 如果你还没有这样做,请报告VS错误(如果还没有修复)https://connect.microsoft.com/VisualStudio (3认同)
  • 我知道这种方法的动机可能只是为了解决 IDE 问题,但您在定义“friend”函数时给出了关于冗余的合理论点。为什么这不是默认的实现方法?这只是 C++ 哲学的问题还是只是碰巧“朋友”成为最常见的哲学?除了类本身之外的其他人会调用“swap”,这是一种常见的情况吗? (2认同)
  • @VillasV参见http://stackoverflow.com/questions/5695548/public-friend-swap-member-function (2认同)

Ker*_* SB 14

在处理C++ 11风格的分配器感知容器时,我想添加一个警告.交换和赋值具有微妙的不同语义.

为了具体,我们考虑一个容器std::vector<T, A>,其中A是一些有状态的分配器类型,我们将比较以下函数:

void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{ 
    a.swap(b);
    b.clear(); // not important what you do with b
}

void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
    a = std::move(b);
}
Run Code Online (Sandbox Code Playgroud)

这两种功能的目的fs,并fm为给a该国b已经开始.然而,有一个隐藏的问题:如果会发生什么a.get_allocator() != b.get_allocator()?答案是:这取决于.我们来写吧AT = std::allocator_traits<A>.

  • 如果AT::propagate_on_container_move_assignmentstd::true_type,则将fm分配器重新分配a给值b.get_allocator(),否则不重新分配,并a继续使用其原始分配器.在这种情况下,数据元素需要单独交换,因为存储ab不兼容.

  • 如果AT::propagate_on_container_swapstd::true_type,fs则以预期的方式交换数据和分配器.

  • 如果AT::propagate_on_container_swapstd::false_type,那么我们需要动态检查.

    • 如果a.get_allocator() == b.get_allocator(),则两个容器使用兼容的存储,并以通常的方式进行交换.
    • 但是,如果a.get_allocator() != b.get_allocator()程序具有未定义的行为(参见[container.requirements.general/8]).

结果是,只要容器开始支持有状态分配器,交换就变成了C++ 11中的一个非常重要的操作.这是一个有点"高级用例",但并非完全不可能,因为一旦您的类管理资源,移动优化通常只会变得有趣,而内存是最受欢迎的资源之一.


归档时间:

查看次数:

335450 次

最近记录:

8 年,7 月 前