zoukankan      html  css  js  c++  java
  • [C++ Primer] : 第12章: 动态内存

    动态内存与只能指针

    静态内存用来保存局部static对象, 类static数据成员以及定义在任何函数之外的变量. 栈内存用来保存定义在函数内的非static对象. 分配在静态或栈内存中的对象由编译器自动创建和销毁. 栈中的对象, 仅在其定义的程序块运行时才存在; static对象在使用之前分配, 在程序结束时销毁.
    自由空间或堆用来存储动态分配的对象——即在程序运行时分配的对象. 动态对象的生存期由程序来控制, 也就是说, 当动态对象不在使用时, 我们的代码必须显示地销毁它们.

    动态内存与智能指针
    C++中动态内存的管理是通过new和delete运算符来完成.
    新标准库提供了3种智能指针来管理动态对象, 它们可以自动释放所指的对象. 它们都定义在memory头文件中.

    智能指针
    shared_ptr
    unique_ptr
    weak_ptr
    shared_ptr和unique_ptr都支持的操作
    shared_ptr<T> sp         空智能指针, 可以指向类型为T的对象
    unique_ptr<T> up
    p                        将p用作一个条件判断, 若p指向一个对象, 则为true
    *p                       解引用p, 获得它指向的对象
    p->mem                   等价于(*p).mem
    p.get()                  返回p中保存的指针, 要小心使用, 如果智能指针指针释放了其对象, 返回的指针所指的对象也就消失了
    swap(p, q)               交换p和q中的指针
    p.swap(q)
    shared_ptr独有的操作:
    make_shared<T>(args)     是一个标准库函数, 返回一个shared_ptr, 指向一个动态分配的类型为T的对象. 使用args初始化此对象, args为空, 则执行值初始化.
    shared_ptr<T> p(q)       p是shared_ptr q的拷贝, 此操作会递增q中的计数器, q中的指针必须能转换成T*
    p = q                    此操作会递增q的引用计数, 递减p的引用计数, 若p的引用计数变为0, 则将其管理的原内存释放
    p.unique()               若p.use_count()为1, 返回true, 否则返回false
    p.use_count()            返回与p共享对象的智能指针数量, 可能很慢, 主要用于调试.
    

    每一个shared_ptr都有一个关联的计数器, 通常称其为引用计数. 拷贝一个shared_ptr, 引用计数会递增, 给shared_ptr赋予一个新值或被销毁, 计数器递减.

    make_shared函数
    最安全的分配和使用动态内存的方法是调用一个名为make_shared的标准库函数. make_shared用其参数来构造给定的对象.

    shared_ptr<string> p1;       // 可以指向string
    shared_ptr<list<string>> p2; // 可以指向string的list
    shared_ptr<int> p3 = make_shared<int>(42); // 指向一个值为42的int的shared_ptr
    shared_ptr<string> p4 = make_shared<string>(3, '9'); // 指向值为"999"的string
    shared_ptr<int> p5 = make_shared<int>(); // 指向一个值初始化的int, 值为0
    auto p6 = make_shared<vector<string>>();
    auto p7(p6); // p6, p7指向相同的对象.
    
    class MyClass
    {
    public:
      typedef std::shared_ptr<MyClass> sptr;
      static sptr make()
      {
        return sptr(new MyClass());
      }
      static sptr make2(int x, int y)
      {
        return make_shared<MyClass>(x, y);
      }
      MyClass(int x1 = 0, int y1 = 0): x(x1), y(y1) { }
      ~MyClass(){ }
    private:
      int x;
      int y;
    };
    
    int main()
    {
      MyClass::sptr sp_mc = MyClass::make(); // 返回智能指针管理的动态类对象
      sp_mc = MyClass::make2(10, 24); // sp_mc原来指向的对象内存会释放, 然后sp_mc指向一个新对象.
      shared_ptr<MyClass> sp_mc2 = make_shared<MyClass>(14, 20);
      return 0;
    }
    

    如果将shared_ptr存放在一个容器中, 而后不再需要全部元素, 而只使用其中一部分, 要用erase删除不再需要的那些元素.

    程序使用动态内存的三个原因:

    • 程序不知道自己需要多少资源, 如容器类是出于该原因而使用动态内存的典型例子.
    • 程序不知道所需对象的准确类型.
    • 程序需要在多个对象间共享数据.

    直接管理内存
    new与delete, new[]和delete[]成对使用.
    自由空间分配的内存是无名的, 因此new无法为其分配的对象命名, 而是返回一个指向该对象的指针.
    默认情况下, 动态分配的对象是默认初始化的, 这意味着内置类型或组合类型的对象的值将是未定义的, 而类类型对象将用默认构造函数进行初始化.
    也可以使用直接初始化方式, 可以使用传统的构造方式(使用圆括号), 在新标准下, 也可以使用列表初始化(使用花括号), 还可以进行值初始化, 只需在类型名后面跟一对空括号即可. 对于定义了自己的构造函数的类类型来说, 值初始化和默认初始化是一样的, 都会调用默认构造函数来初始化.

    int *pi = new int(1024);        // 直接初始化, 用传统的构造方式(使用圆括号)
    int *ps = new string(10, '9');  // 直接初始化, 用传统的构造方式
    vector<int> *pv = new vector<int>{0,1,2,3} // 直接初始化, 用初始化列表的方式
    string *ps1 = new string;       // 默认初始化, 空string, 调用默认构造函数
    string *ps2 = new string();     // 值初始化, 初始化为空string
    int *pi1 = new int;             // 默认初始化, *pi1的值未定义
    int *pi2 = new int();           // 值初始化为0
    

    如果我们提供了一个括号包围的初始化器, 就可以使用auto来从此初始化器来推断我们想要分配的对象的类型. 由于编译器需要要初始化器的类型来推断要分配的类型, 只有当括号中仅有单一初始化器时才可以使用auto.

    auto p1 = new auto(obj);         // p1指向一个与obj类型相同的对象, 该对象用obj来初始化
    auto p2 = new auto{a, b, c};     // 错误, 括号中只能有单个初始化器.
    

    动态分配的const对象
    一个动态分配的const对象必须进行初始化, 由于分配的对象是const的, new返回的指针是一个指向const的指针.
    默认情况下, new如果不能分配所要求的内存空间, 它会抛出一个类型为bad_alloc的异常, 而C中的malloc则会返回一个NULL指针.
    定位new: 定位new表达式允许我们向new传递额外参数.

    int *p1 = new int; // 如果分配失败, new抛出std::bad_alloc
    int *p2 = new (nothrow) int; // 如果分配失败, new返回一个空指针
    

    传递给new一个有标准库定义的名为nothrow的对象, 意思是告诉它不能抛出异常, 如果这种形式的new不能分配所需的内存, 就会返回一个空指针, 而不会抛出异常. bad_alloc和nothrow都定义在new头文件中.

    释放动态内存
    delete接受一个指针, 指向我们想要释放的对象. delete表达式执行两个动作: 销毁给定的指针所指向的对象; 释放对应的内存.
    传递给delete的指针必须指向动态分配的内存, 或者是一个空指针. 释放一块并非new分配的内存, 或者将相同的指针释放多次, 其行为是未定义的. 释放一个空指针总是没有错误的, 它们通常什么也不做.
    通常情况下, 编译器不能分辨一个指针指向的是静态还是动态分配的对象, 类似的, 编译器也不能分辨一个指针所指向的内存是否已经被释放. 传递给delete一个非new分配的内存指针, 尽管大多数编译器可以编译通过, 但它们是错误的, 其行为将是未定义的.
    虽然一个const对象的值不能被改变, 但它本身是可以被销毁的.
    有内置指针(而非智能指针)管理的动态内存在被显示释放之前一直都会存在.
    使用new和delete管理动态内存存在3个常见的问题:

    • 忘记delete内存.
    • 使用已经释放掉的内存.
    • 同一块内存释放两次.

    delete之后记得重置指针值. 在delete之后, 指针就变成了人们所说的空悬指针, 将nullptr赋予指针, 这样就清楚地指针指针不再指向任何对象. 但即使是这样, 这也只是提供了有限的保护, 因为还有可能有多个指针指向相同的内存. delete之后重置指针的办法只对这个指针有效, 对其他仍指向这块以释放内存的指针是没有作用的.

    shared_ptr和new结合使用
    如果不初始化一个智能指针, 它就会被初始化为一个空指针. 可以用new返回的指针来初始化智能指针. 接受指针参数的智能指针构造函数是explicit的, 因此不能将一个内置指针隐式转换为一个智能指针, 必须使用直接初始化形式来初始化一个智能指针

    shared_ptr<double> p1;
    shared_ptr<int> p1 = new int(1024);  // 错误, 必须使用直接初始化形式
    shared_ptr<int> p2(new int(1024));   // 正确, 使用直接初始化形式.
    shared_ptr<int> clone(int p)
    {
        //return new int(p); //错误, 返回shared_ptr的函数不能在其返回语句中隐式转换一个普通指针.
        return shared_ptr<int>(new int(p)); //正确, 显示地用int *创建shared_ptr<int>
    }
    

    默认情况下, 一个用来初始化智能指针的普通指针必须指向动态内存, 因为智能指针默认使用delete来释放它所关联的对象. 也可以将智能指针绑定到一个指向其他类型的资源的指针上, 但是为了这样做, 必须提供自己的操作来代替delete.

    shared_ptr<T> p(q); // p管理内置指针q所指向的对象, q必须指向new分配的内存且能够转换为T*类型
    shared_ptr<T> p(u); // p从unique_ptr u那里接管对象的所有权, 将u置为空.
    shared_ptr<T> p(q, d); // p接管了内置指针q所指向的对象的所有权. q必须能够转换为T*类型, p将使用可调用对象d来代替delete.
    shared_ptr<T> p(p2, d); // p是shared_ptr p2的拷贝, 唯一的区别是p将用可调用对象d来代替delete
    p.reset(); // 若p是唯一指向其对象的shared_ptr, reset会释放此对象. 若传递了可选的参数内置指针q, 会令p指向q, 否则会将p置空. 若还传递了可调用对象d, 则会使用d而不是delete来释放对象.
    p.reset(q);
    p.reset(q, d);
    

    不要混合使用普通指针和智能指针
    shared_ptr可以协调对象的析构, 但这仅限于自身的拷贝(也是shared_ptr)之间, 这也是为什么推荐使用make_shared而不是new的原因.
    当将一个shared_ptr绑定到一个普通指针时, 我们就将内存的管理责任交给了这个shared_ptr, 一旦这样做了, 就不应该再使用内置指针来访问shared_ptr所指向的内存了.

    void process(shared_ptr<int> ptr)
    {
        //do something
    } // ptr离开作用域, 被销毁
    shared_ptr<int> p(new int(42)); // 引用计数为1
    process(p);                     // 拷贝p会递增其引用计数, 在process中其引用计数为2
    int i = *p;                     // 正确, 引用计数值为1
    int *x(new int(1024));          // 危险: x是一个普通指针, 而非智能指针
    //process(x);                   // 错误, 不能隐式转换
    process(shared_ptr<int>(x));    // 创建一个临时的shared_ptr对象传递给process, process结束后该临时对象会被销毁, 此时其引用计数为0, x指向的内存会被释放.
    int j = *x;                     // 未定义的行为, x是一个空悬指针.
    

    也不要使用get初始化另一个智能指针或为智能指针赋值:
    智能指针的get函数返回一个智能指针, 指向智能指针管理的对象, 此函数是为了这样一种情况而设定: 我们需要向不能使用智能指针的代码传递一个内置指针. 使用get返回指针的代码不能delete此指针.

    shared_ptr<int> p(new int(42));
    int *q = p.get();               //正确, 但是注意不要让它管理的指针被释放
    {
        // 新的程序块
        shared_ptr<int>(q)          // 未定义行为, 两个独立的shared_ptr指向相同的内存
    } // 程序块结束, q被销毁, 它指向的内存被释放
    int foo = *p;                   // 未定义: p指向的内存已经被释放了
    

    只有在确定代码不会delete指针的情况下才能使用get, 特别是永远不要使用get初始化另一个智能指针或者为另一个智能指针赋值.

    其他shared_ptr操作
    reset经常和unique一起使用, 来控制多个shared_ptr共享的对象.

    if(!p.unique) // 如果不是唯一用户就做一份拷贝
        p.reset(new string(*p));
    *p += newVal; // 此时自己是唯一用户了
    

    智能指针和异常

    确保在异常发生后资源能被正确的释放.

    struct destination;
    struct connection;
    connection connect(destination*);
    void disconnect(connection);
    void f(destination &d/*其他参数*/)
    {
        // 获得一个连接; 记住使用完后要关闭它
        connection c = connect(&d);
        // 使用连接
        // 如果在f退出前忘记调用disconnect或是由于异常而无法调用disconnect, 就无法关闭c了.
    }
    // 使用shared_ptr来保证connection被正确关闭, 这是一种有效的方法.
    void end_connection(connection *p) // 提供自己的删除器
    {
        disconnect(*p);
    }
    void f(destination &d/*其他参数*/)
    {
        connection c = connect(&d);
        shared_ptr<connection> p(&c, end_connection); // 默认会使用delete来释放资源, 但是可以提供自己的删除器
        //使用连接
        //当f退出时(即使是由于异常而退出), connection会被正确关闭
    }
    

    智能指针陷阱:

    • 不要使用相同的内置指针初始化或reset多个智能指针. 因为这会导致个智能指针独立创建, 各自的引用计数器是独立的, 即不知道还有其他智能指针指向同一块内存.
    • 不要delete有get()返回的指针.
    • 不要使用get()初始化或reset另一个智能指针.
    • 如果使用get()返回的指针, 谨记当最后一个对应的智能指针销毁后, 你的指针就变为无效了.
    • 如果你使用智能指针管理的资源不是new分配的内存, 记住传递给它一个删除器.

    unique_ptr
    与shared_ptr不同, 任一时刻, 只能有一个unique_ptr指向一个给定的对象, 也没有类似make_shared的标准库函数返回一个unique_ptr. 当我们定义一个unique_ptr时, 需要将其绑定到一个new返回的指针上. 类似与shared_ptr, 初始化unique_ptr时也必须采用直接初始化方式. unique_ptr由于独占对象, 故不支持普通的拷贝和赋值操作.

    unique_ptr<T> u1       空unique_ptr, u1会使用delete来释放其指针, u2使用一个类型为D的可调用对象来释放指针
    unique_ptr<T, D> u2
    unique_ptr<T, D> u(d)  空unique_ptr, 使用类型为D的可调用对象d代替delete.
    u = nullptr            释放u指向的对象, 将u置空
    u.release()            u放弃对指针的控制权, 返回指针, 并将u置空
    u.reset()              释放u所指的对象, 如果提供了内置指针q, 令u指向这个对象, 否则将u置空.
    u.reset(q)
    u.reset(nullptr)
    

    虽然无法拷贝或赋值unique_ptr, 但可以通过调用release或reset将指针的所有权从一个unique_ptr转移给另一个unique.

    unique_ptr<string> p2(p1.release()); // p1放弃对指针的控制权, 返回指针, 并将p1置空
    unique_ptr<string> p3(new string("Trex"));
    p2.reset(p3.release()); // 释放p2, p3置空, p2指向p3曾将指向的内存.
    p2.release();           // 错误, p2不会释放内存, 而且丢失了指针.
    auto p = p2.release();  // 正确, 但是必须记得delete(p)
    

    release会切断unique_ptr与它原来管理的对象之间的联系. reset则会释放其所指的资源.
    不能拷贝unique_ptr的规则有一个例外
    我们可以拷贝或赋值一个将要被销毁的unique_ptr, 最常见的例子是从函数返回一个unique_ptr.

    unique_ptr<int> clone(int p)
    {
        return unique_ptr<int>(new int(p));
    }
    unique_ptr<int> clone(int p)
    {
        unique_ptr<int> ret(new int (p));
        // ...
        return ret;
    }
    

    编译器知道要返回的对象将要被销毁, 在此情况下, 编译器执行一种特殊的拷贝.

    向unique_ptr传递删除器

    // 其他函数同shared_ptr
    void f(destination &d/*其他参数*/)
    {
        connection c = connect(&d); // 打开连接
        unique_ptr<connection, decltype(end_connection)*> p(&c, end_connection);
        //使用连接
        //当f退出时(即使是由于异常而退出), connection会被正确关闭
    }
    

    weak_ptr
    weak_ptr是一种不控制所指对象生存期的智能指针, 它指向一个由shared_ptr管理的对象, 将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数. 一旦最后一个指向对象的shared_ptr被销毁, 对象就会被释放, 即使有weak_ptr指向对象, 对象还是会被释放.

    weak_ptr<T> w      空weak_ptr
    weak_ptr<T> w(sp)  与shared_ptr指向相同对象的weak_ptr. T必须能够转换为sp指向的类型
    w = p              p可以是一个weak_ptr或一个shared_ptr, 赋值后w与p共享对象
    w.reset()          将w置空
    w.use_count()      与w共享对象的shared_ptr的数量
    w.expired()        若w.use_count()为0, 则返回true, 否则返回false.
    w.lock()           如果expired为true, 返回一个空shared_ptr, 否则返回一个指向w的对象的shared_ptr.
    

    当创建一个weak_ptr时, 需要用一个shared_ptr来初始化它.

    auto p = make_shared<int>(42);
    weak_ptr<int> wp(p);  // wp弱共享p, p的引用计数未改变
    

    由于对象可能不存在, 我们不能直接用weak_ptr直接访问对象, 而必须调用lock. 此函数检查weak_ptr指向的对象是否仍存在. 如果存在, lock返回一个指向共享对象的shared_ptr.

    if(shared_ptr<int> np = wp.lock()) { // 先执行赋值, 再判断np是否为空
        // 在if语句中, np与p共享对象
    }
    

    动态数组

    大多数应用应该使用标准库容器而不是动态分配的数组. 使用容器更为简单, 更不容易出现内存管理错误并且可能有更好的性能.
    new和数组

    int *p = new int[get_size()]; // p指向第一个int的指针, 大小必须是整型, 但不必是常量
    

    动态数组不是数组类型, 因此不能对动态数组调用begin和end.
    默认情况下, new分配的对象都是默认初始化的, 可以对数组进行值初始化, 大小后面跟上一对空括号即可.

    int *pia = new int[10];    // 10和未初始化的int
    int *pia2 = new int[10](); // 10个值初始化的int
    string *psa = new string[10];    // 10个空string
    string *psa2 = new string[10](); // 10个空string
    int *pia3 = new int[10]{1, 2, 3}; // 前3个用列表中对应的初始化器初始化, 剩余的进行值初始化
    

    新标准中还可以提供一个元素初始化器的花括号列表. 如果初始化器数目小于元素数目, 剩余元素将进行值初始化, 如果初始化器数目大于元素数目, 则new表达式失败, 不会分配任何内存.
    虽然可以用空括号对数组中的元素进行值初始化, 但不能在括号中给出初始化器, 这意味着不能用auto分配数组. new用于非数组时可以在括号内提供初始化器, 用于数组时则不行.

    可以动态分配一个空数组, new会返回一个合法的非空指针, 它保证与new返回的其他任何指针都不相同, 此指针就像尾后指针一样, 不能解引用.

    char arr[0]; // 错误, 不能定义长度为0的数组
    char *cp = new char[0]; // 正确, 但是cp不能解引用
    

    释放动态数组必须要使用delete[], 忽略方括号其行为是未定义的.

    智能指针和动态数组:
    标准库提供了一个可以管理new分配的数组的unique_ptr版本. 为了用一个unique_ptr管理动态数组, 必须在对象类型后面跟上一对空方括号.

    unique_ptr<int[]> up(new int[10]{1, 2, 3});
    for(size_t i = 0; i != 10; ++i)
      up[i] = i;   // 可以用下标访问数组中的每一个元素
    up.reset(); // 调用delete[]释放动态内存
    

    指向数组的unique_ptr不支持成员访问运算符(点和箭头运算符), 因为unique_ptr指向的是一个数组而不是单个对象. 可以使用下标运算符来访问数组中的元素.

    指向数组的unique_ptr的操作:

    unique_ptr<T[]> u;
    unique_ptr<T[]> u(p);
    u[i];                   返回u拥有的数组中位置i处的对象.
    

    shared_ptr不直接支持管理动态数组, 如果希望用shared_ptr管理一个动态数组, 必须提供自己定义的删除器.

    // 传递一个lambda作为删除器, 它使用delete[]来释放数组.
    shared_ptr<int> sp(new int[10], [](int *p){ delete[] p; });
    // shared_ptr没有定义[]运算符, 并且不支持指针的算数运算
    for(size_t i = 0; i != 10; ++i)
      *(sp.get() + i) = i;  // 使用get()获取一个内置指针
    sp.reset(); // 使用我们提供的lambda释放数组, 它使用delete[]
    

    如果未提供删除器, 则代码是未定义的, 因为shared_ptr默认使用delete而不是delete[]来销毁对象.
    shared_ptr未定义下标运算符, 而且智能指针类型不支持指针算术运算. 为访问数组中的元素, 必须用get()获取一个内置指针, 然后用它来访问数组元素.

    allocator类
    new将内存分配和对象构造结合在了一起. delete将对象析构和内存释放组合在一起. 这些使得new有一些灵活上的局限性.
    当分配一大块内存时, 通常计划在这块内存上按需构造对象, 此时我们希望将内存分配和对象构造分离. 着意味着只在真正需要时才真正执行对象创建操作.
    一般情况下, 将内存分配和对象构造组合在一起可能导致不必要的浪费.

    string *const p = new string[n]; // 构造n个空的string
    string s;
    string *q = p;
    while(cin >> s && q != p + n)
      *q++ = s;
    const size_t size = q - p;
    // 使用数组
    delete[] p;
    

    new初始化并分配了n个string. 但是可能不需要n个string. 这样可能就创建了一些永远用不到的对象. 对于确实需要使用的对象, 初始化之后立即赋予新值, 每个使用到的元素都被赋值两次. 更重要的是, 那些没有默认构造函数的类就不能动态分配数组了.
    allocator类定义在头文件memory中, 它提供一种类型感知的内存分配方法, 它分配的内存时原始的, 未构造的.
    allocator是一个模板, 当一个allocator对象分配内存时, 它会根据给定的对象类型来确定恰当的内存大小和对齐位置.

    标准库allocator类及其算法:

    allocator<T> a          定义一个名为a的allocator对象, 它可以为类型为T的对象分配内存
    a.allocate(n)           分配一段原始的, 未构造的内存, 保存n个类型为T的对象
    a.deallocate(p, n)      释放从T*指针p中地址开始的内存, 这块内存保存了n个类型为T的对象; p必须是一个先前由allocate返回的指针, 且n必须是p创建时所要求的大小. 在调用deallocate之前, 用户必须对内存中的每个对象调用destory()
    a.construct(p, args)    p是一个T*的指针, 指向一块原始内存, args被传递给类型为T的构造函数, 在p所指的内存中构造一个对象.
    a.destroy(p)            p为T*类型的指针, 此算法对p所指的对象执行析构函数.
    
    allocator<string> alloc;          // 可以分配string的allocator对象
    auto const p = alloc.allocate(n); // 分配n个未初始化的string
    auto q = p; // q在后续操作中将指向最后构造的元素之后的位置.
    alloc.construct(q++);              // 空串
    alloc.construct(q++, 3, 'c');      // ccc
    alloc.construct(q++, "hi");        // hi
    cout << *p << endl;  // 正确, p指向第一个已经构造过的对象
    cout << *q << endl;  // 错误, q指向未构造的内存
    while(q != p)
      alloc.destory(--q);   // 释放真正构造的string, 没有释放内存
    alloc.deallocate(p, n); // 释放内存
    

    allocator将内存分配释放和对象构造析构分成了两个阶段: 内存配置由allocate负责, 内存释放由deallocate负责. 对象构造操作由construct负责, 对象析构由destroy负责.

    拷贝和填充未初始化内存的算法
    这些函数在给定目的位置创建元素, 而不是由系统分配内存给它们.
    uninitialized_copy(b, e, b2) 从迭代器b和e指出的输入范围中拷贝元素到迭代器b2指定的未构造的原始内存中
    uninitialized_copy_n(b, n, b2) 从迭代器b指向的元素开始, 拷贝n个元素到b2开始的内存中
    uninitialized_fill(b, e, t) 在迭代器b和e指定的原是内存范围中创建对象, 对象的值均为t的拷贝
    uninitialized_fill_n(b, n, t) 从迭代器b指向的内存地址开始创建n个对象, b必须指向足够大的未构造的原始内存, 能够容纳给定数量的对象.
    以上函数均返回一个指向最后一个构造的元素之后的位置的迭代器.

  • 相关阅读:
    Asp.net使用DevExpress的某些控件不能操作ViewState的解决方案
    关于 vue 循环组件,组件内有根据要求请求select下拉列表,组件内还有自身组件,select下拉列表无法正确获取的问题解决
    Vue+axios请求本地json
    关于vuevideoplayer 实现跳转到特定位置并自动播放
    VueQuillEditor回显不显示空格的处理办法
    elementui 的CascaderPanel级联面板类型 懒加载 回显
    elementui 中的文本域的autosize的意思
    解决 [Element Warn][Form]model is required for validate to work!
    初涉simulink
    arm学习计划
  • 原文地址:https://www.cnblogs.com/moon1992/p/7507625.html
Copyright © 2011-2022 走看看