zoukankan      html  css  js  c++  java
  • Effective C++读书笔记~4 设计与声明

    条款18:让接口容易被正确使用,不易被误用

    Make Interfaces easy to use correctly and hard to use incorrectly.

    如果想要开发一个“容易被正确使用,不容易被误用”的接口,考虑客户可能犯什么样的错误呢?
    假设用一个用来表现日期的class设计构造函数:

    class Date {
    public:
        Date(int month, int day, int year);
    };
    

    客户可能会犯这样的错误,而编译器不会报错:

    // 参数顺序错误
    Date d(30, 3, 1995); // 正确应该是"3,30" 而不是"30,3"
    // 传递无效月份或天数
    Date d(2, 30, 1995); // 正确应该是"3,30" 而不是"2,30"
    

    如何避免客户犯这样的错误?

    1.导入新类型

    struct Day {
        explicit Day(int d) : val(d) {}
        int val;
    };
    struct Month {
        explicit Month(int m) : val(m) {}
        int val;
    };
    struct Year {
        explicit Year(int y) : val(y) {}
        int val;
    };
    
    class Date {
    public:
        Date(const Month& m, const Day& d, const Year& y);
    };
    
    // 客户使用接口创建Date对象
    Date d(30, 3, 1995); // 编译器报错, 参数类型不正确
    Date d(Day(30), Month(3), Year(1995)); // 编译器报错, 参数类型不正确
    Date d(Month(3), Day(3), Year(1995)); // OK
    

    2.预先定义有效的数值范围

    导入新类型可以解决输入参数类型错误问题,但不能解决参数值的范围不正确的问题。比如,一年12个月,但实际可以输入13。此时,可以利用enum表现月份,不过enum不具备类型安全性,安全的做法是预先定义有效的Month:

    // 预先定义月份enum
    enum Month{ Jan, Feb, ..., Dec };
    
    // 预先定义有效的Month class
    class Month {
    public:
        static Month Jan() { return Month(1); }
        static Month Feb() { return Month(2); }
        ...
        static Month Dec() { return Month(1); }
        ...
    private:
        explicit Month(int m); // 构造函数设为private, 阻止别处生成新的月份
    };
    
    // 客户像这样使用月份Month作为参数, 就比较安全
    Date d(Month::Mar(), Day(30), Year(1995));
    

    3.限制类型内什么事可做,什么事不能做

    常见限制是加上const。对于不希望客户修改的内容,加上const限定。

    4.让types容易被正确使用,不容易被误用

    内置types有什么样的行为,你的types应该尽量保持一致,除非有好的理由。
    比如,a, b都是int,那么对a * b赋值不合法。

    5.为了提供行为一致的接口

    不依赖于客户必须记得做某些事情,才能保证 “一致性”。
    比如,条款13中,工厂方法createInvestment()返回一个动态分配对象,要求客户必须记得删除指针。如果没有删除,或者删除同一个指针超过1次,就会造成错误,从而产生 “不一致性”。

    Investment* pInv = Factory::createInvestment();
    ...
    delete pInv; // 需要依赖客户调用delete 释放资源
    

    使用shared_ptr(或unique_ptr)可以解决这个问题,因为shared_ptr提供了默认的deleter,即使客户忘记,也会自动释放资源,同时也保留了客户指定deleter的能力。

    shared_ptr<Investment> pw(Factory::createInvestment()); // 使用完后, 自动调用delete释放资源
    ...
    shared_ptr<Investment> pw2(Factory::createInvestment(), getRidOfInvestment); // 使用完后, 调用客户指定的删除器getRidOfInvestment释放资源
    ...
    

    小结

    1)好的接口容易被正确使用,而不容易被误用。应该在所有自定义接口中尽量做到。
    2)“促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
    3)“阻止误用”的办法包括建立新类型、限制类型上的操作,限制对象值范围,以及消除客户的资源管理责任。(一致性不应依赖客户)
    4)shared_ptr/unique_ptr支持自定义删除器(custom deleter)。可用来自动解除互斥锁、自动释放资源等。

    [======]

    条款19:设计class犹如设计type

    Treat class design as type design.
    当定义一个新class,也就定义了一个新type。

    如何设计高效的classes?

    首先了解你必须面对的问题。面对以下问题,会影响设计规范:

    新type的对象应该如何被创建和销毁?
    影响class的构造函数和析构函数,以及自定义内存分配函数和释放函数(operator new, operator new[], operator delete, operator delete[])的设计。

    对象的初始化和对象的赋值该有什么样的差别?
    决定你的构造函数和赋值(assignment)操作符行为,以及之间的差异。“初始化”和“赋值”,对应不同的函数调用;

    新type的对象,如果被passed by value(值传递),意味着什么?
    pass-by-value传递对象时,如传递参数,或者返回值,会调用copy构造函数构建新对象。

    什么是新type的“合法值”?
    对于class成员变量值,通常只有某些值(或者数值集合)才是有效的。数值集决定了class必须维护的约束条件(invariants),也就决定了你的成员函数(特别是构造函数、赋值操作符、所谓setter函数)必须进行的错误检查工作。也影响函数抛出的异常、以及(很少使用的)函数异常明显列(exception specifications)。

    你的新type需要配合某个继承图系(inheritance graph)吗?
    如果你继承自某些既有的classes,就得受到那些classes的设计的束缚,特别函数是virtual或者non-virtual的影响。如果你允许其他classes继承你的class,那会影响你所声明的函数 -- 特别是析构函数, 是否为virtual。

    你的新type需要什么样的转换?
    你的type有转换行为吗?如果希望允许类型T1被隐式转换为类型T2,那就必须在class T1内写一个类型转换函数(operator T2),或者在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。
    如果只允许explicit的构造函数存在,也就不支持隐式构造转换,就得写出专门负责执行转换的函数,且不得为类型转换操作符或non-explicit-one-argument构造函数。(条款15有隐式和显示转换范例)

    什么样的操作符合函数对此新type而言是合理的?
    决定你将为你的class声明哪些函数。其中,某些应该是member函数,某些则否(见条款23,24,46)

    什么样的标准函数应该驳回?
    必须声明为private,或者=delete(C++11)的函数。

    谁该取用新type的成员?
    可以帮助决定哪个成员为public,哪个为protected,哪个为private。也帮助决定哪个classes/functions应该是friends,以及将它们嵌套于另一个之内是否合理。

    什么是新type的“未声明接口”(undeclared interface)
    它对效率、异常安全性(条款29)以及资源运用(如多任务锁定,动态内存)提供何种保证?你在这方面提供的保证将为你的class实现代码加上相应的约束条件。

    你的新type有多么一般化?
    或许你其实并非定义一个新type,而是定义一整个types家族。如果确实如此,不应该定义一个新class,而是一个新class template。

    你真的需要一个新type吗?
    如果只是定义一个新derived class以便为现有的class添加功能,那么单纯定义一个或多个non-member函数或者templates,或许更简单有效。

    小结

    1)Class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过本条款覆盖的所有讨论主题了。

    [======]

    条款20:宁以pass-by-reference-to-const替换pass-by-value

    Prefer pass-by-reference-to-const to pass-by-value

    对于对象类型

    用const引用(pass-by-reference-to-const)传递对象,替换值传递(pass-by-value)。因为值传递不仅会多创建一个副本的开销,而且对副本的修改不会影响原来对象。

    对于内置类型

    用值传递替换const引用传递,因为C++编译器底层 pass by reference本质是传递的指针,pass by value的效率通常比pass by reference要高。

    小结

    1)尽量以pass-by-reference-to-const替换pass-by-value。前者通常更高效,并可以避免切割问题(因为值传递会重新构造对象)。
    2)以上规则不适用于内置类型,STL的迭代器和函数对象。对它们而言,pass-by-value通常更合适。

    [======]

    条款21:必须返回对象时,别妄想返回其reference

    Don't try to return a reference when you must return an object

    既然pass-by-value(传值)会有构造和析构的开销,那么是不是要消除所有pass-by-value,改用pass-by-reference-to-const呢?
    答案是否定的。在函数返回对象时,如果返回值是reference(引用),可能会导致错误。

    以有理数(rational numbers)的class,求乘积为例;

    class Rational {
    public:
        Rational(int numberator = 0, int denonminator = 1);
    private:
        int n, d;
        friend const Rational& operator* (const Rational& lhs, const Rational& rhs);
    };
    

    返回值是引用有一个前提,该对象已经存在。而对于下面这种情况,就不太合理,因为原本就不存在 a*b 这样一个Rational对象

    Rational a(1, 2); // a = 1/2
    Rational b(3, 5); // b = 3/5
    Rational c = a * b; // c是3/10, a * b 如果返回的是引用, 就会产生冲突, 因为a*b的Rational对象不存在, 也就无从谈起引用
    

    1)如果返回的引用指向stack上的local 对象,返回后对象销毁,无法通过引用访问函数的local对象,否则会造成内存访问异常。

    // case1: 返回的引用指向local对象
    const Rational& operator*(const Rational& lhs, const Rational& rhs)
    {
        Rational res(lhs.n * rhs.n, lhs.d * rhs.d); // local 对象, 离开函数作用域后销毁
        return res;
    }
    // 存在的问题: 调用者无法正常使用, 因为stack上的对象已经释放
    

    2)如果返回的引用指向heap-based对象,首先得构造一个对象,然后是要求调用者在使用完毕后释放对象资源。

    // case2: 返回的引用指向heap-based对象
    const Rational& operator*(const Rational& lhs, const Rational& rhs)
    {
        Rational *res = new Rational(lhs.n * rhs.n, lhs.d * rhs.d); // heap-based对象
        return *res;
    }
    ...
    // 存在的问题: 要求调用者主动释放
    Rational a(1, 2); // a = 1/2
    Rational b(3, 5); // b = 3/5
    Rational c = a * b;
    delete c;
    
    // 存在的问题: 也可能造成内存泄漏
    Rational w, x, y, z;
    w = x * y * z; // <=> ((x * y) * z), 其中, 没有指针指向(x * y), 对应heap-based内存对象无法释放
    

    3)如果返回的引用是static对象,则不仅存在线程安全的问题,而且存在无法得出正确的计算结果的问题。

    // case3: 返回的引用指向static对象
    const Rational& operator*(const Rational& lhs, const Rational& rhs)
    {
        static Rational res; // static 对象
        res = ...; // lhs * rhs, 用来更新res
        return res;
    }
    
    // 存在的问题: 函数带状态; 无法判断相等的问题
    bool operator==(const Rational& lhs, const Rational& rhs);
    Rational a, b, c, d;
    ...
    if ((a * b) == (c * d)) { // 存在的问题: 该条件恒成立
        
    }
    else {
    
    }
    

    if ((a * b) == (c * d)) 等价于if (opeartor==(operator(a, b), operator(c, d))),调用opeartor ==判断时,static对象已经更新,等号两边都是同一状态,因此恒相等。

    小结

    绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer/reference指向一个local static对象,而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference指向一个local static对象”提供了一份设计实例。

    [======]

    条款22:将成员变量声明为private

    Declare data members private.

    为什么成员变量不应该是public?

    因为如果public,客户就没有必要调用函数来获取、修改成员变量,而是直接访问成员变量。当成员变量修改时,调用者代码必须修改。

    为什么成员变量不应该是protected?

    derived class能直接访问protected成员变量,而无需通过函数来获取、修改。当protected成员变量修改时,所有用到它的derived class都必须修改。可以说,protected成员变量是更高级的public变量。

    而修改了的代码,都要重新测试、编写文档、编译。从封装角度来看,只有2种访问权限:private(提供封装)和其他(不提供封装)。

    private成员变量的优势

    用函数提供对private成员变量的public访问,如果成员变量的计算方式有变更,但访问方法可以不变,这样客户代码无需修改。也就是说,private可以提供对成员变量的封装,对客户隐藏成员变量细节,保留日后变更的权利。

    小结

    1)切记将成员变量声明为private。可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现灵活性。(private封装了成员变量,函数才有发挥的余地)
    2)protected并不比public更具封装性。(protected是更高级的public,两者封装性是一样的)

    [======]

    条款23:宁以non-member、non-friend替换member函数

    Prefer non-member non-friend functions to member functions.

    假设有个class用来表示网页浏览器,提供3个清理函数:

    class WebBrowser
    {
    public:
           void clearCache(); // 清除下载元素高速缓冲区
           void clearHistory(); // 清除访问过的URLs
           void removeCookies(); // 清除系统中的所有cookies
    };
    

    如果用户想要一次执行所有这些动作,是为class 添加一个clearEverything成员函数,还是添加一个non-member函数?

    // choice1 为class添加成员函数
    class WebBrowser
    {
    public:
           ...
           void clearEverything() {
               clearCache();
               clearHistory();
               removeCookies();
           }
    };
    
    // choice2 添加一个non-member函数
    void clearBrowser(WebBrowser& wb) {
        wb.clearCache();
        wb.clearHistory();
        wb.removeCookies();
    }
    

    member,non-member函数,选择哪个比较好?为什么?

    答:choice2 添加一个non-member函数是较好的选择。这是因为,choice2导致class封装性更好。
    具体地,越多函数可访问class的成员变量(数据),数据的封装性就越低。class的成员变量应该是private(条款22),能访问private成员变量的函数只有member函数 + friend函数。一个member函数,和一个non-member,non-friend函数,这2者如果提供相同的功能,那么导致较大封装性的是non-member non-friend函数,因为它不增加“能够访问class内的private成分”的函数数量。
    也就是说,non-member函数(clearBrowser)导致WebBrowser class有较大的封装性。

    注意:
    (1)从对class封装性角度看,member函数 = friend函数 < non-member, non-friend函数。
    (2)non-member, non-friend函数不意味着不能是另外一个class的member,比如,可以成为某个工具类(utility class)的一个static member函数。只要不是class WebBrowser的一部分即可。

    如何设置non-member函数?

    C++的自然做法是,
    (1)让clearBrowser成为一个non-member函数,并且位于WebBrowser的同一个namespace下。
    (2)像class WebBrowser可能有大量像clearBrowser这样的non-member便利函数,某些与书签(bookmarks)有关,某些与打印有关,还有些与cookie的管理有关。而客户通常只关心某些。这样,可以把这些便利函数按不同功能分类,把相同功能的集中声明到一个头文件。这也是标准库STL的做法。

    比如,

    // webbrowser.h 声明class WebBrowser本身
    namespace WebBrowserStuff {
    class WebBrowser{...};
    ...
    }
    
    // webbrowserbookmarks.h
    namespace WebBrowserStuff {
    class WebBrowser{...};
    ... // 与书签有关的便利函数
    }
    
    // webbrowsercookies.h
    namespace WebBrowserStuff {
    class WebBrowser{...};
    ... // 与cookie有关的便利函数
    }
    

    小结

    1)宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、封装灵活性(packaging flexibility)和功能扩展性。

    [======]

    条款24:若所有参数皆需类型转换,请为此采用non-member函数

    Declare non-member functions when type conversions should apply to all parameters.

    假设你现在想要通过class Rational支持有理数的乘法,而允许class将整数“隐式转换”似乎颇为合理。
    现在有两种做法:1)为class添加member函数,重载operator *;2)添加non-member函数,重载operator *。

    为class添加member函数

    class Rational {
    public:
           // 构造函数为non-explicit, 允许隐式转换
           Rational(int numerator = 0, int denominator = 1) : n(numerator),  d(denominator) { }
           int numerator() const { return n; } // 分子
           int denominator() const { return d; } // 分母
           const Rational operator*(const Rational& rhs) const; // 有理数乘法
    private:
           int n;
           int d;
           // ...
    };
    
    // member函数重载operator*, 计算有理数乘法
    const Rational Rational::opeartor*(const Rational& rhs) const
    {
        return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() *  rhs.denominator());
    }
    
    // 用户用来进行有理数乘法运算
    Rational a(1, 2);
    Rational b(3, 5);
    Rational res = a * b; // OK
    res = a * 2; // OK. a是Ration对象, "opeartor *"需要一个Rational, 于是2隐式转换为Rational(2, 1)
    res = 2 * a; // 错误: 2无法直接转换为Rational对象
    

    为什么"res = a * 2"没有问题,而"res = 2 * a"却错误?
    因为a是一个Rational对象,class重载了opeartor * ,编译器识别到运算符右侧需要一个Rational对象,再加上Rational构造函数是non-explicit,会将2隐式转换为Rational(2, 1)。这样,"res = a * 2"相当于:

    const Rational temp(2); // 建立一个临时性的Rational对象
    res = a * temp; // <=> res.operator *(temp);
    

    而对于"res = 2 * a",2从一开始就无法转换为Rational对象,导致编译报错。

    添加non-member函数

    将class member函数operator*,替换为non-member函数版本,"res = 2 * a"就不会出错了,2会自动隐式转换为Rational(2, 1)。另外,non-member没必要成员Rational class的一个friend函数,因为non-friend函数完全可以利用Rational的public接口实现其功能,使用friend只会降低class的封装性。

    class Rational {...};
    
    // non-member函数 重载operator *, 计算有理数乘法
    const Rational operator*(const Rational& lhs, const Rational& rhs)
    {
           return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() *  rhs.denominator());
    }
    
    // 用户用来进行有理数乘法运算
    Rational a(1, 2);
    Rational b(3, 5);
    Rational res = a * b; // OK
    res = a * 2; // OK
    res = 2 * a; // OK
    

    小结

    1)如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。
    2)如果non-member可以是non-friend函数,尽量使用non-friend。

    [======]

    条款25:考虑写出一个不抛出异常的swap函数

    Consider support for a non-throwing swap.

    swap函数是STL的一部分,也是异常安全性编程(exception-safe programming,条款29)的脊柱,以及用来处理自我赋值(条款11)的一个常见机制。
    本条款探讨swap实现复杂度和应用方法。

    swap(置换)2个对象值,将两个对象的值赋予对方。缺省swap动作由STL(标准程序库)提供swap算法。

    swap典型实现

    namespace std {
           // std::swap典型实现, 置换a, b的值
           template<typename T>
           void swap(T& a, T& b) 
           {
                  T temp(a); // 用a拷贝构造temp对象
                  a = b; // b赋值给a
                  b = temp; // temp赋值给t
           }
    }
    

    前提是类型T支持copying函数(copy构造函数,copy assignment操作符)。其过程是:
    1)用a拷贝构造临时对象temp;
    2)b复制到a;
    3)temp复制到b;

    对于基本类型,这种交换方法效率很高。但对于某些类型,这些拷贝构造、复制过程没有必要,最主要就是“以指针指向一个对象,内含真正数据”的那种类型,直接交换双方指针即可。

    如何置换class指针成员指向的存储空间?

    例如,如何置换下面的2个Widget对象值?

    // 针对Widget数据设计的class
    class WidgetImpl { 
    public:
        ...
    private:
        // 可能有很多数据, 也就是说复制时间可能很长
        int a, b, c;
        std::vector<double> v;
        ...
    };
    
    // class Widget持有一个指针pImpl, 指向一个WidgetImpl对象
    class Widget {
    public:
        Widget(const Widget& rhs){ ... }
        Widget& opeartor=(const Widget& rhs)
        {
            ...
            *pImpl = *(rhs.pImpl); // WidgetImpl对象赋值
            ...
        }
        ...
    private:
        WidgetImpl* pImpl; // 指针, 指向WidgetImpl数据, 是Widget数据
    };
    

    要置换2个Widget对象值,只需要置换pImpl指针即可。如果还是用缺省的std::swap,将还是会复制3个Widget和WidgetImpl对象,效率将很低下。

    一个简单的想法是,特例化std::swap函数模板:

    // 以下代码无法通过编译
    namespace std {
        template<> // 这是std::swap针对 T是Widget 的特例化版本
        void swap<Widget>(Widget& a, Widget& b)
        {
            swap(a.pImpl, b.pImpl); // 错误:置换Widgets只需要置换它们的指针pImpl, 问题在于pImpl都是private
        }
    }
    

    template<> 表明是模板特例化,表示这一特例化是针对“T是Widget”而设计。当swap的2个参数是Widget类型时,就会启用这个特例化swap。
    问题在于:pImpl是class Widget的private成员,无法直接访问。于是可以为class添加名为swap函数的public member。

    // OK. 可以通过编译
    class Widget {
    public:
        ...
        void swap(Widget& other) // member函数
        {
            using std::swap; // 令std::swap在此函数内可用
            swap(pImpl, other.pImpl); // 调用的是std::swap函数, 置换指针pImpl
        }
        ...
    };
    
    namespace std {
         // std::swap 特例化版本
       template<>
       void swap<Widget>(Widget& a, Widget& b)
       {
           a.swap(b);
       }
    }
    

    上面代码可以通过编译,与STL容器有一致性。

    如何置换class template?

    假设Widget, WidgetImpl都是class templates而非classes,那么要如何置换?
    首先可以将Widget, WidgetImpl内的数据加以参数化:

    template<typename T>
    class WidgetImpl {...};
    
    template<typename T>
    class Widget {...};
    

    接下来,偏特化(partially specialize)一个class templates

    namespace std {
        template<typename T>
        void swap(Widget<T>& a, Widget<T>& b) // std::swap的一个重载版本, swap之后没有"<...>"(为什么会去掉?)
        {
            a.swap(b);
        }
    }
    

    偏特化class templates时,swap后的"<...>"为什么会去掉?
    因为带有"<...>"表示是特化(特例化)或者偏特化,取决于前面template中的<>,是否带有与class一致的类型参数,如果是,就是偏特化;如果是空,表示特化。
    而swap不带"<...>",表明是为function templates std::swap添加了一个重载版本。

    禁止膨胀std的template

    从语法层面,上面重载std::swap的做法做没有问题,可以编译和执行,但是却破坏了std的完整性,因为C++标准委员会规定:可以全特化std内的templates,但禁止膨胀那些已经声明好的对象。而重载function template(std::swap)属于膨胀行为。

    如何解决这个问题?
    答:可以在一个新的命名空间内WidgeStuff,添加一个swap的non-member函数。为了简化起见,也可以把Widget相关的class都置于该命名空间内。

    namespace WidgetStuff {
        template<typename T>
        class WidgetImpl {...}; // 模板化的class
    
        template<typename T>
        class Widget {...}; // 模板化的class, 内含member swap函数
    
        template<typename T>
        void swap(Widget<T>& a, Widget<T>& b) // non-member swap函数, 注意这里并非std命令空间, swap非重载函数
        {
            a.swap(b);
        }
    }
    

    完整代码(示范 & 测试):

    点击查看代码
    namespace WidgetStuff {
        template<typename T>
        class WidgetImpl
        {
        public:
            WidgetImpl() {
                static int count = 0;
                a = count++;
                b = count++;
                c = count++;
    
                v.push_back(a);
                v.push_back(b);
                v.push_back(c);
            }
            void print() {
                cout << "a = " << a << ", b = " << b << ", c = " << c << endl;
                cout << "vector = [";
                for (auto &e : v) {
                    cout << e << " ";
                }
                cout << "]" << endl;
            }
        private:
            int a, b, c;
            vector<double> v;
        };
    
        template<typename T>
        class Widget
        {
        public:
            Widget() {
                pImpl = new WidgetImpl<T>;
            }
    
            Widget(const Widget<T>& rhs) {
                if (!rhs.pImpl) {
                    pImpl = nullptr;
                    return;
                }
                *pImpl = *(rhs.pImpl);
            }
    
            ~Widget() {
                if (pImpl) delete pImpl;
            }
    
            Widget& operator=(const Widget& rhs) {
                *pImpl = *(rhs.pImpl);
                return *this;
            }
    
            void swap(Widget<T>& other) {
                using std::swap;
                swap(pImpl, other.pImpl);
            }
    
            void print() {
                pImpl->print();
            }
    
        private:
            WidgetImpl<T>* pImpl;
        };
    
        template<typename T>
        void swap(Widget<T>& a, Widget<T>& b) {
            a.swap(b);
        }
    }
    
    int main() {
        using namespace WidgetStuff;
    
        Widget<int> w1;
        Widget<int> w2;
        w1.print();
        w2.print();
        swap(w1, w2);
        cout << "------swap data------------" << endl;
        w1.print();
        w2.print();
        return 0;
    }
    
  • 相关阅读:
    restframwork框架
    restful规范
    python_微信 跳一跳
    项目经历1
    python3.6+GDAL-2.1.3环境配置
    OpenCV&&python_图像平滑(Smoothing Images)
    opencv python3.6安装和测试
    python_机器学习—sklearn_win_64-3.6安装&&测试
    python安装 numpy&安装matplotlib& scipy
    自动出题判分——c#学习实践
  • 原文地址:https://www.cnblogs.com/fortunely/p/15574733.html
Copyright © 2011-2022 走看看