精华内容
下载资源
问答
  • 背景如果要设计一个C++的类,哪怕是最基础不过的类,也需要涉及到以下知识点。能够熟悉并且驾驭这些知识点,是能够写出一个C++类的前提。此外,这些知识点也如同一个checklist,在review代码时候可以对照着逐项...

    背景

    如果要设计一个C++的类,哪怕是最基础不过的类,也需要涉及到以下的知识点。能够熟悉并且驾驭这些知识点,是能够写出一个C++类的前提。此外,这些知识点也如同一个checklist,在review代码的时候可以对照着逐项进行检查:

    • 对象的初始化方式;
    • 构造函数;
    • 拷贝构造函数、赋值运算符;
    • 移动构造函数、移动赋值运算符;
    • 析构函数;
    • 继承体系;
    • Rule of Three/Five;
    • 访问控制;
    • 传值和传引用;
    • const语义;
    • 智能指针作为类的成员;
    • 异常处理;
    • 标准库使用;
    • 模板设计模式;
    • 线程安全及可重入;

    上述内容中,“线程安全及可重入”、“模板设计模式”是非常大的话题,本文不涉及,请参考本专栏的其它文章;“异常处理”、“标准库使用”的内容比较多,本文只是捎带着提及,详细的讨论在本专栏的其它文章中。

    基础类型在新标准中的初始化

    开始本文前,先提及下内置类型在C++11标准后新增的初始化方式。

    基础类型如下所示:

    • bool
    • char系列:char、wchar_t、char16_t、char32_t;
    • int系列:short、int、long、long long;
    • float系列:float、double、long double;

    孔乙己吃着茴香豆说,茴字有四样写法,你知道么? 在c++里,基础类型也有四种初始化写法,你知道吗?

    int 

    其中后两者就是c++11新增的list initialization。基础类型如果没有像上面这样使用四种方式显式进行初始化的话,那么就会:

    • 如果定义在函数外,那么会被默认初始化为0;
    • 如果定义在函数内,则默认初始化为undefined值;

    类的构造函数

    构造函数决定了一个类的对象是如何被初始化的。

    1,默认构造函数与合成默认构造函数

    没有参数的构造函数叫做默认构造函数(default constructor),如果一个类没有定义任何构造函数,那么编译器会自动生成一个,称之为合成默认构造函数(synthesized default constructor)。一旦类中用户显式的定义了任何的构造函数,那么编译器将停止自动生成默认构造函数。那么编译器生成的构造函数是如何知道怎么初始化当前类的各个成员呢?原则很简单:

    • 如果成员有in-class initializer,那么就用它初始化该成员;
    • 反之,就default-initialize该成员。

    有意思的事情来了。你来说说下面这个简单的代码中,类Gemfield初始化的时候,其成员a_分别会被初始化几次呢?

    class A{
    public:
        A(int i){}
    };
    
    //1st
    class Gemfield{
    public:
        Gemfield(){}
        A a_={3};
    };
    
    //2nd
    class Gemfield{
    public:
        Gemfield():a_(4){}
        A a_={3};
    };
    
    //3rd
    class Gemfield{
    public:
        Gemfield():a_(4){a_= 5;}
        A a_={3};
    };

    请原谅我这里使用了带参数的构造函数来举例,主要是为了区分不同的初始化,原理一样。

    • 第一种情况,成员a_会使用3作为构造函数的参数初始化1次;
    • 第二种情况,成员a_会使用4作为构造函数的参数初始化1次;
    • 第三种情况,成员a_会使用4作为构造函数的参数初始化1次,然后在构造函数体内部使用参数5进行一个临时对象的构造(代码是等号右边的{5} ),然后会调用合成的assign operator将该临时对象赋值给a_,这是converting constructor的一个典型场景。

    因为默认构造函数在很多场景下都会被用到(比如:定义一个局部对象却没有使用initializer的时候;作为别的类的成员而该类又有默认构造函数的时候,等等),因此,设计一个类的最佳实践之一就是:总是为该class设计一个默认构造函数

    2,用户定义的构造函数

    前面已经提到过,一旦类中用户定义了任何的构造函数,那么编译器将会停止自动生成默认构造函数。那么如果这种情况下,我们依然想要编译器自动生成一个默认构造函数,那该怎么办呢?使用新标准中的:

    Gemfield() = default;

    用户定义构造函数是更常见的行为,因为编译器合成的默认构造函数在大多数情况下都不能满足我们的需要,比如:

    • 类中需要自定义的资源初始化方式;
    • 类中某些成员并没有默认构造函数;

    在用户定义的构造函数中,最重要的一个事情就是构造函数初始化列表(constructor initializer list)。如果编译器不支持in-class initializer的话,就只能使用constructor initializer list了。总而言之,在进入用户定义的构造函数体之前,类的成员已经被初始化过一遍了,使用的正是如下的三种方式之一(三选一):

    • default initialize;
    • in-class initializer;
    • constructor initializer list;

    而一旦进入构造函数体之后,再想修改该类的成员,就只能使用assign operator了。那么什么情况下只能使用构造函数初始化列表(constructor initializer list)来对类的成员进行初始化呢?以下四种情况:

    • 成员是const类型;
    • 成员是引用类型;
    • 成员的类型没有定义默认构造函数;
    • 追求构造效率的;

    3,代理构造函数(delegating constructor)

    C++11还新增了delegating constructor,就是一个构造函数的构造函数初始化列表(constructor initializer list)部分可以换成其它参数列表的构造函数,然后再追加自身的函数体。

    4,转换构造函数(Converting constructors)

    当构造函数可以使用一个参数进行调用的时候,这个构造函数就是转换构造函数(Converting constructors)。构造函数可以使用一个参数进行调用意味着以下两种情况:

    • 构造函数只有一个参数;
    • 构造函数有多个参数,但除了第一个外都有默认值;

    也就是说,转换构造函数(Converting constructors)定义了从构造函数的参数类型到当前class type的隐式转换。转换构造函数说的是一种类型通过构造函数转换为当前的class类型,那么这种行为的反义词——如何将当前class的类型转换为其它类型——是什么呢?Conversion operator

    转换构造函数(Converting constructors)有什么用呢?两种场景。

    • 当赋值操作的时候,比如前面遇到过的代码:
    class A{
    public:
        A(int i){}
    };
    
    class Gemfield{
    public:
        Gemfield():a_(4){a_= 5;}
        A a_={3};
    };

    这里的a_ = 5能直接赋值,将int类型转化为了class A类型,就是因为class A的构造函数正是converting constructor。

    • 当传参的时候,比如函数的形参类型是A,但是我们实参可以传递一个int类型。

    注意,Converting constructors只允许同时进行一种类型的转换,比如形参类型是B,而B拥有一个Converting constructors,其参数类型为string,这个时候如果你传递字符串"this is gemfield test"是不行的,因为字符串到string是一次类型转换,而string到class B又是一个类型转换,同时有两次转换是不行的。解决方案就是你可以在这两处任选一处做个显式的强制类型转换。

    标准库里有这样的用法吗?有。比如string类:

    string gemfield = "this is gemfield test";

    如果我想禁用转换构造函数(Converting constructors),那该怎么办呢?使用explicit关键字。explicit用在声明的地方,不能用在定义的地方。值得注意的是,只有在转换构造函数(Converting constructors)上添加explicit关键字才有意义,如果构造函数有多个参数(也即不是converting constructor),那本来也没有隐式的类型转换,那填不填加explicit就没有意义了。

    一旦使用explicit关键字禁用了转换构造函数(Converting constructors),那么构造函数只能接收一样的类型来直接初始化了,或者使用显式的强制类型转换先将参数的类型转成一致的。

    5,继承构造函数(inherit constructor)

    C++11后,我们可以在子类中使用using关键字来复用基类的构造函数:

    class D : public B{
        public:
            using B::B;
    };

    称之为继承构造函数(inherit constructor)。通过使用using关键字,我们将基类class B中的所有的构造函数都以如下的形式在子类class D中重新定义了一遍:

    D(params) : B(args) {};

    这种继承构造函数有如下特点需要在实践中注意:

    • 不管在哪写using,子类构造函数的access level和基类一样(public、private、protected);
    • 不能自行添加explicit和constexpr,但是如果基类有explicit或constexpr那么子类也有,基类没有子类也没有;
    • 基类构造函数中有默认参数的话,子类会去掉默认参数,并且生成多个版本的构造函数;比如基类只有一个构造函数(带两个参数,第二个参数有默认值),那么子类就会生成两个构造函数,其中一个有两个参数,另外一个只有一个参数;
    • 如果基类中有多个构造函数,子类也会生成同样数量的构造函数(先不考虑上述的默认参数情况);但如果子类手动实现了其中一种,那只有其它的构造函数才会被继承过来;
    • 自动合成的默认构造函数不会被继承,子类会由编译器使用同样的规则自动合成;
    • 继承而来的构造函数并不会被当做用户自定义构造函数,因此,如果子类中只有这种继承而来的构造函数,那么编译器就会自动合成默认构造函数;

    拷贝构造函数

    如果构造函数的第一个参数是该类的引用类型,并且其它参数都有默认值,那么这个构造函数就是拷贝构造函数(copy constructor)。只要用户不自定义拷贝构造函数,编译器就会自动合成。合成拷贝构造函数的特点就是,它会执行对象之间的memberwise copy来进行对象的拷贝构造。拷贝构造函数在多个场景下都会用到:

    • 拷贝初始化(Copy initialization),使用一个对象初始化另一个对象的时候,比如:
    A a1 = a2;

    注意,一定要和赋值运算符区分开来。等号左边是未初始化的对象时,这是拷贝构造;如果是已经初始化过(包括默认初始化),则是赋值运算符,比如下面这样就是赋值运算符:

    A a1;
    
    //a1已经默认初始化了
    a1 = a2;
    • 函数调用时候的传参(形参类型不是引用),也就是传值的时候会调用拷贝构造函数。这个很有趣,也解释了为什么拷贝构造函数的参数必须是引用类型。假设拷贝构造函数的参数类型不是引用而是传值,那么传值就要调用拷贝构造函数,这就变成了鸡生蛋和蛋生鸡的问题了。
    • 接收返回值,且返回值类型不是引用的时候;
    • 使用大括号初始化数组里的元素、aggregate class的成员的时候;

    注意,在标准库的容器添加元素的时候,insert、push等是copy initialization,而emplace操作是直接初始化。

    赋值运算符

    这个和拷贝构造函数类似,值得说的地方有3处(以编译器自动合成的赋值运算符为例):

    • 为了和内置类型的行为一致,赋值运算符的返回值为等号左边操作符的引用;
    • 参数不一定要为引用类型,因为没有鸡生蛋的问题;但最好是reference to const类型;
    • 如果成员是数组类型,数组中的每个元素都会被赋值操作;

    为什么叫赋值运算符而不是赋值构造函数呢?因为构造函数是没有返回值的,而赋值运算符是有返回值的。返回值的类型最好是non-const reference,并且指向等号左边的对象。为什么呢?假设返回的是void或者其它类型,那么对于 a = b = c这样的表达式怎么办呢?哈哈哈哈。

    实践中一定要注意对参数进行检查,防止自己赋值给自己的情况发生。

    移动构造函数和移动赋值运算符

    移动语义是要作用在右值引用(Rvalue references)上的,你可以参考这篇文章:

    Gemfield:C++的类型推导zhuanlan.zhihu.com
    da2fcbb923a38ec35b723ceee4ea2946.png

    在这篇文章中,你可以得知——比如吧,变量是“左值”,那么右值引用类型定义的变量也是“左值”,因此右值引用变量类型定义的变量(gemfield)是无法绑定到另一个右值引用类型变量(gemfield2)上的:

    int&& gemfield = 7030;
    
    //错误
    int&& gemfield2 = gemfield;
    
    //正确,你可以使用std::move将gemfield转换为右值
    int&& gemfield2 = std::move(gemfield);

    好了,回到本文。移动构造函数在C++11中出现的意义——或者说为了解决的问题——是对象在资源的管理中如何拥有更高的效率。比如,在如下的情况下,move一个“资源”显然要比copy一个资源在效率上获得极大提升:

    • a=b,当把b拷贝给a时,并且b又不再需要的时候。这个时候move就要好于copy;把b的资源直接由a托管,比“a中申请空间+b拷贝给a+销毁b” 要快的多;

    在如下的情况下,只有move一个“资源”才有意义:

    • unique_ptr1 = unique_ptr2;因为unique_ptr只能独享资源,这个时候用move再合适不过了;
    • IO对象,因为IO对象的buffer无法共享;

    这些“资源”就和我们前面提到的右值引用要产生关系了。根据上面的例子,我们得到一个重要的事实就是:右值引用(Rvalue references)引用的是那些即将销毁的对象,这也是为什么我们从右值引用上“移走”想要的资源是合理的。

    标准库中的容器、string、shared_ptr支持copy和move,标准库中的IO和unique_ptr只有move语义(就像上面说的那样)。

    一个类必须具备移动构造函数才具备移动语义,其对象才支持move操作,就像上面的容器、string这样的类。那么如何为自己的类定义一个移动构造函数呢?我们可以换个视角,因为移动构造函数和拷贝构造函数最相似,我们就和拷贝构造函数来比较,看看这两者不一样的地方是什么:

    • 移动构造函数的形参类型是右值引用类型,和拷贝构造函数的reference to const是不一样的:
    Gemfield
    • 移动构造函数的逻辑必须确保被move的对象处于一种无害状态,具体来说,被move的对象必须不再拥有其之前管理的资源,因为这些资源已经由新的对象接管了;被move的对象还需要能够被安全的析构;
    • 移动构造函数不参与新的资源的申请,因此一般不会throw异常,于是最好是被noexcept修饰;此外,noexcept修饰的移动构造函数还具备其它的意义,标准库能否看到这个noexcept承诺会有不一样的行为。比如vector在push_back一个对象时,如果该对象的move constructor不能承诺noexcept,那么vector会转而使用这个对象的copy constructor,以确保vector在reallocation错误的时候,不会犯下回不去的错误。

    那么移动构造函数会像拷贝构造函数那样被编译器自动合成吗(如果用户没有自定义的话)?回顾一下,对于默认构造函数、拷贝构造函数、赋值运算符、析构函数来说,当用户没有声明且程序中使用到了该类型的构造、拷贝、赋值、析构,那么编译器会自动合成相应的默认构造函数、拷贝构造函数、赋值构造、析构函数;但是对于移动构造函数来说,情况就完全不一样了。

    编译器只有在以下情况下才会自动合成移动构造函数

    • 用户没有声明拷贝构造函数,且
    • 用户没有声明赋值运算符(copy assignment operator),且
    • 用户没有声明移动赋值运算符(move assignment operator),且
    • 用户没有声明析构函数,且
    • 所有非static成员都是可moveable的,且
    • 父类们都是可moveable的。

    编译器只有在以下情况下才会自动合成移动赋值运算符

    • 用户没有声明拷贝构造函数,且
    • 用户没有声明赋值运算符(copy assignment operator),且
    • 用户没有声明移动构造函数(move constructor),且
    • 用户没有声明析构函数,且
    • 所有非static成员都是可moveable的,且
    • 父类们都是可moveable的。

    和拷贝构造不同的是,编译器不会自动合成=delete的移动构造函数,但如果我们主动使用=default来申请编译器来合成移动构造函数,而当前的类又因为下列情况而不满足move条件,那么合成的移动构造函数就是=delete的:

    • 成员的类型定义了copy但没定义move;
    • 成员类型的move是=delete的或者不可访问(比如private);
    • 析构函数是=delete的或者不可访问;
    • 如果类有const或者reference类型,则合成移动赋值运算符会被=delete;

    当然,编译器不自动合成那就用户自定义呗。

    析构函数

    和构造函数一样,析构函数也没有返回值;和构造函数又不一样,析构函数没有参数,因此没有重载——也就是说一个类中只有一个析构函数。如果用户不定义析构函数,编译器会合成一个析构函数,该析构函数的函数体为空。

    构造函数的函数体是在成员都初始化完毕之后再执行的,与之相反,析构函数则是在函数体执行完毕再开始销毁类的成员的。

    在日常的实践中,如果你设计的类有虚函数,或者你的类有可能被继承,那么就将析构函数加上virtual关键字,成为虚析构函数。为什么呢?如果析构函数不是virtual的,很可能在代码中会出现base指针指向子类的对象,这个时候如果delete base指针就会导致未定义的行为。

    The Rule of Three/Five

    理解了类中资源的管理,你就会得出如下结论:

    • 如果该类显式的定义了析构函数,则一般也需要显式的定义拷贝构造函数、赋值运算符(除非使用=delete直接禁止该类的拷贝和赋值);
    • 如果该类显式的定义了拷贝构造函数,则也需要显式的定义赋值运算符;反之亦然;
    • 我们应该把拷贝构造、赋值运算符、移动构造、移动赋值运算符、析构函数看成一个整体,如果用户定义了其中一个,就应该定义所有的;

    这就是C++11之前的Rule of Three,以及C++11之后的Rule of Five。

    再来讨论下自动合成

    阅读完前面的文章,你也许看到或者在其它地方听说过,有些情况下“移动构造函数不会被合成,有些情况下“移动构造函数会被合成为=delete”,那这两者有什么区别呢?

    • “=delete”参与函数重载,如果被匹配到了,那就不好意思啦——编译报错;
    • “不会被合成“表示没有被合成/根本不存在,那就不参与函数重载。

    下面就列出一些场景,在这些场景下,编译器会自动合成相关的函数,并且被标记为=delete:

    • 如果class中有一个成员的析构函数为=delete或者不可访问(比如private),那么该class的自动合成析构函数也会被标记为=delete;
    • 如果class中有一个成员的拷贝构造函数为=delete或者不可访问(比如private),或者析构函数为=delete或者不可访问(比如private),那么该class的自动合成拷贝构造函数也会被标记为=delete;
    • 如果class中有一个成员的赋值运算符为=delete或者不可访问(比如private),或者有成员的类型为const或者引用,那么该class的自动合成赋值运算符也会被标记为=delete;
    • 如果class中有一个成员的类型为引用但是并没有in-class的initializer,或者有一个成员的类型为const但是没有默认构造函数和in-class initializer, 那么该class的自动合成默认构造函数也会被标记为=delete;
    • 如果用户定义了移动构造函数或者移动赋值运算符,那么合成的拷贝构造函数和赋值运算符会被标记为=delete;

    这里面其它规律还好说,但是=delete的析构函数会导致合成默认构造函数、拷贝构造函数也为=delete就让人猝不及防了。这是因为如果允许这种情况发生,那就会导致默认构造的对象无法析构。

    如果感觉上面自动合成的场景有点复杂,那么在实践中就这么做,永远显示的定义这些函数。要么自定义,要们使用=default、=delete等向编译器主动申请(C++11之后)。

    继承体系

    OOP的思想在于封装、继承、多态。前面对于资源的封装我们已经感受的差不多了,现在来简单说说继承吧。

    继承体系下,在构造函数、拷贝构造、赋值运算符、移动构造函数中,子类需要显式的初始化基类部分;而在析构函数中,子类只需要关心子类的部分。

    继承体系下,类的构造函数/析构函数中调用该类的虚函数,则虚函数的版本不是常规的多态中的行为,而是使用当前构造函数/析构函数所属的类中的虚函数版本。

    继承在实践中有一些关键的点:

    • 不想让别人继承的话,自己的类加上final;
    • 基类中的virtual函数,在子类中永远是virtual函数,不管在子类中加不加virtual关键字;
    • override关键字告诉用户:这是一个virtual函数, 而且是正在覆写基类中的虚函数;并且让编译器帮着做个检查,看是不是在覆写基类中的虚函数,而不是在创建一个新的函数(手抖什么的);

    对于编译器自动合成的那些函数来说:

    • 如果基类的默认构造函数、拷贝构造函数、赋值运算符、析构函数被标记为=delete或者是不可访问的,那么子类中相应的成员也是=delete,这很好理解;
    • 如果基类的析构函数是=delete或者是不可访问的,那么子类中自动合成的默认构造函数、拷贝构造函数也会被标记为=delete;
    • 如果基类的析构函数是=delete或者是不可访问的,那么在子类中想用=default来请求编译器自动合成移动构造函数的话,移动构造函数也会被标记为=delete;
    • 如果基类中的移动构造函数是deleted或者是不可访问的,那么子类中想用=default来请求编译器自动合成移动构造函数的话,移动构造函数也会被标记为=delete。

    类的成员使用智能指针而不是裸指针

    在普通函数里,我们都知道尽量要使用智能指针来管理动态内存。原因很简单,裸指针容易导致内存泄漏。让Gemfield来举个例子吧:

    Gemfield* getGemfieldPtr(){
        Gemfield* p_gem = new Gemfield();
        return p_gem;
    }

    在这个例子中,当函数返回后,是调用者的责任来确保p_gem指向的内存被释放掉。万一:

    • 调用者忘记了呢?
    • 当前的调用者没有忘记,后来新的feature添加后,新的代码忘记了呢?
    • 调用者没有忘记,但代码因为异常等原因,函数提前返回从而没走到delete逻辑呢?
    • 调用者的逻辑里有多个指针变量指向同样的内存,导致多次delete呢?

    种种原因,导致我们在实践中一定要使用智能指针。这块的内容,请参考:

    Gemfield:C++的智能指针zhuanlan.zhihu.com

    那么回到本篇文章,当写一个C++的类时,如果类成员是指针类型,那么是用裸指针好还是智能指针好呢?试想下,如果使用裸指针的话,一般情况下,我们在构造函数中为其分配内存,在析构函数中释放其内存,听起来不错。但是再考虑以下的问题呢:

    • 类中没有定义析构函数,使用的是自动合成的析构函数......;
    • 构造函数中,刚刚为该成员new了内存,之后构造函数抛异常了......;
    • 该类的对象之间互相拷贝、赋值时,如果是合成拷贝构造函数,裸指针......;如果是用户定义的拷贝构造函数、赋值运算符,裸指针......;
    • 该类的对象使用移动语义时,裸指针......;

    考虑到种种情况,我们在类成员中也要尽量避免使用裸指针,转而使用智能指针。那么是使用shared_ptr还是unique_ptr呢?

    • 如果class1和class2之间需要共享成员的话,指针成员使用shared_ptr;
    • 如果class1和class2之间不共享成员的话,指针成员使用unique_ptr;
    • 如果是单例模式的话,指针成员使用unique_ptr;

    对于类的成员函数来说(好吧,也适用于非成员函数),使用智能指针的一些实践原则如下所示:

    • 如果在函数中返回的资源想要由调用者管理的话,返回unique_ptr,调用者之后可以自己再自由发挥,比如可以把它赋给shared_ptr(如果想要的话);
    • 从函数中返回shared_ptr相对比较少,如果出现这种情况,那意思就是:函数的设计者想要延长该函数中创建的资源的生命周期;
    • 从函数中很少返回weak_ptr,从调用者角度来看的话,如果调用者无从知晓返回的对象是否还在其生命周期中,则可以考虑使用weak_ptr;
    • 如果从函数中返回的资源的生命周期并不由调用者介入或者管理,并且也管理不了,那么函数返回裸指针或者引用。

    异常处理和stack unwinding

    你设计的类的代码需要抛出异常吗?你需要catch自己代码(包括自己代码调用的三方库)的异常吗?只有清楚了解这些问题以及其后的背景,才能做到当异常被抛出的时候,程序依然能够行为正常,也就是常说的exception safe的代码。

    比如,如果在类的构造函数中发生了异常呢?可能和很多人的直觉相反,在构造函数中因为错误而主动throw异常是标准行为。因为构造函数没有返回值,人们是无从得知其构造是否成功,如果有错误,就抛出异常。至于资源释放问题,请参考上述的智能指针环节。

    class A{
        public:
            A(int i){i_ = i;}
            ~A(){}
        private:
            int i_;
    };
    
    class Gemfield{
        public:
            Gemfield(){a2 = new A(2); throw std::exception();}
            ~Gemfield(){}
        private:
            A a1{1};
            A* a2;
    };

    上面的代码片段中,类Gemfield的构造函数中抛出了异常,请回答以下问题:

    • Gemfield的析构函数会被调用吗?
    • a1的析构函数会被调用吗?也就是a1会被释放吗?
    • a2的析构函数会被调用吗?a2 new的资源会被释放吗?

    如果在类的析构函数中发生了异常呢?应该在析构函数中抛出异常吗?这个小节的内容可以参考本专栏文章:C++的异常处理。

    类设计中的其它小项

    1,访问控制

    就是public、protected、private这些关键字。注意,访问控制修饰符定义的是的访问权限,而不是对象的访问权限。什么意思呢?比如Gemfield类有两个对象,g1和g2,g1是可以访问g2的private成员的。

    不然拷贝构造、赋值运算符是怎么访问参数对象里的私有成员的呀,哈哈哈哈。

    2,函数传参和返回值

    传值还是传引用?一切为了效率出发。下面列举几个经典场景:

    1. 参数需要在函数内修改,并在函数外使用修改后的值:传引用
    2. 参数需要在函数内修改,但在函数外使用修改前的值:传值
    3. 参数不会被修改,参数类型为基础类型的话传值,为class类型的话传引用,并且是reference to const(参考:https://zhuanlan.zhihu.com/p/91075706
    4. 有些类型不允许copy(比如标准IO类型),则只能传引用

    一个函数绝对不要返回其内部局部变量的引用或者指针(不过,有一个常见的用法是类成员函数返回对this对象的引用)。另外,在C++11及之后,可以返回一个braced list了。

    3,const成员函数

    是否需要使用const关键字来修饰成员函数呢?const成员函数表明当前对象的“只读”性,一旦成员函数使用了const来修饰,就表明成员函数上隐式的this指针是reference to const的(注意:this本来就是const的),因此:

    1. const对象上只能调用const成员函数;
    2. 非const对象上既可以调用非const成员函数,也可以调用const成员函数。

    否则报错:error: 'this' argument to member function 'xxxx' has type 'const X', but function is not marked const。

    4,使用C++标准库

    C++标准库不用白不用,关键是,在任何一个支持C++的环境上,都默认有标准C++库(其它的库就没有这个地位了),所以我们应该优先使用标准库;标准库不满足的,优先使用header-only的库。C++标准库主要有:

    • 顺序容器;vector、deque、list、forward_list、array、string;
    • 关联容器;map、set、multimap、multiset、unordered_map、unordered_set、unordered_multimap、unordered_multiset;
    • 通用算法;160多个通用算法、容器相关的算法。

    关于C++标准库,请参考本专栏的其它文章:C++的标准库。

    展开全文
  • 表与之间映射关系 与表 字段与属性 3.sql语言 DDL create创建 alter修改 drop删除 DML 查询语句:select 新增:insert 删除:delete 修改:update DCL 授权... DQL :***SELECT 数据 (...

    oracle

    1.表与表之间的关系

    一对一

    一对多|多对一 主外键关联

    多对多 中间表

    2.表与类之间的映射关系

    类与表

    字段与属性

    3.sql语言

    DDL create创建 alter修改 drop删除

    DML 查询语句:select 新增:insert 删除:delete 修改:update

    DCL 授权...

    DQL

    :***SELECT 数据 (as) 别名|* .. from 数据源(表,结果集,试图) where 行过滤条件 group by 分组字段 having 组过滤信息 order by 排序字段;

    ***多表关联查询

    92 表,表 where 连接条件 外连接:+

    99 表 join 表 on 连接条件 外连接:left join | right join

    索引: 提高大量数据的查询效率(目录)

    序列工具: 帮助定值id等主键字段的值(规律的数字)

    rowid: 行数据的唯一标识,相当于对象的地址-->对重复数据做去重(包括没有主键的表)

    rownum: 结果集的序号 -->分页

    试图: 虚拟表 简化sql,封装sql

    事务: 在做insert delete update时候会自动开启事务

    事务的结束

    asid

    隔离级别: 读已提交

    JDBC: JAVA连接数据库

    步骤:

    1) 加载驱动

    2) 获取连接

    3) sql

    4) 处理块

    5) 发送执行sql获取结果

    5) 关闭资源

    预处理块和静态处理块的区别

    预先编译,提高效率

    防止sql注入

    封装工具类|baosedao

    Mybatis

    持久层框架,简化原生JDBC操作

    1.Mybatis特点

    持久层,半自动化,ORM框架

    2.Mybatis环境搭建

    jar 包( Mybatis核心jar包,依赖jar包,数据库.jar),build path

    mybatis核心配置文件(mybatis.xml|mybatis-cfg.xml|mybatis-config.xml)

    sql映射文件

    测试代码

    3.查询的三个基本功能

    selectOne selectList selectMap

    4.properties实现软编码

    5.teypeLiases 配置别名(单独的类,包)

    6.入参类型

    7.结果类型

    8.事务管理(默认手动提交)

    9.CRUD(增删改查)

    10.接口绑定方案

    11.接口绑定解决多参数问题(@Param)

    12.接口CRUD

    13.批量操作(批量新增,批量修改,批量删除)

    14.动态sql(if..choose..where..set..bind..foreach..)

    15.resultMap实现列明与属性名不一致问题,通过自定义映射关系

    16.多表关联查询,返回的结果在java层面

    Map

    List<Map>

    一个类作为另外一个类的属性,resultMap(association标签关联处理)

    一个类中存在另外一个类的List, resultMap(collection标签关联处理)

    展开全文
  • 平时在用C#做开发朋友都知道,不论是静态成员还是静态方法还是静态类都是用static...假如我们有一个类MyClass,需要实例化这个 类,就应该这么做:MyClass myclass = new MyClass();在这里,MyClass是类,mycla...

    平时在用C#做开发的朋友都知道,不论是静态成员还是静态方法还是静态类都是用static关键字来修饰。

    a3afafaa28a6318036c5794ff3b73034.png

    static关键字

    也就是说只要看到了static,那么它后面的东西就是静态!在c#中,静态成员或者静态方法是属于类的,不是属于对象。假如我们有一个类MyClass,需要实例化这个 类,就应该这么做:

    MyClass myclass = new MyClass();

    在这里,MyClass是类,myclass是对象。如果这个类中有个静态方法static SayHello(),我们调用的时候就应该写成:

    MyClass.SayHello();

    还记得我们写的第一个程序“Hello World"吗? 仔细一看就会发现,原来Main()方法就一个静态方法。

    0a960337715d6699df6bac2afda5e011.png

    经典的Hello World

    Main()方法在Program类中,它是程序的入口点。那么Main()方法为什么要是静态的呢?因为操作系统在调用我们写的程序运行时,首先要找到程序入口点Main()方法,如果Main()方法不是静态的,那么这个方法是属于对象的,也就意味着在调用Main()之前需要先实例化Program类的一个对象,再用对象来调用。那么问题来了,程序入口都进不去,怎么来实例化对象呢?程序无法运行了。所以需要把Main()方法设置为静态方法,这样操作系统在调用这个方法时不需要实例化对象,直接通过类名Program来调用Main()方法即可。

    其实上面我们用到的WriteLine() ReadKey()都是静态方法,它们是属于Console类的,而且Console就是一个静态类!

    接下来做几个测试:

    在Program类中添加一个方法SayHello(),然后在Main()方法中调用它

    c5c09e7bec25bdb929b5779c4d169271.png

    静态方法中调用非静态方法

    我们发现SayHello()下面有红色波浪线条,并提示有错误: 非静态的字段、方法或属性“ConsoleApplication1.Program.SayHello()”要求对象引用。因为我刚刚写的SayHello()方法不是静态的,非静态方法是属于对象的。而Main()方法是静态方法,调用一个非静态方法,我们就得实例化一个对象来调用它,需要修改成这样才可以:

    Program myprogram = new Program();

    myprogram.SayHello();

    或者我们将SayHello()方法改为静态方法,在方法名前面加上static关键字。

    在Program类前加上static,让Program类变成静态类

    a71f634f13f97ba06dca265d6650bf19.png

    静态类

    我们发现又有错误了,

    错误 1 无法声明静态类型“ConsoleApplication1.Program”的变量

    错误 2 无法创建静态类“ConsoleApplication1.Program”的实例

    哦,原来是因为我在Program类名前加了static 那么Program类就成为了一个静态类。静态类是不可以被实例化的,而且静态类中只能有静态方法或者静态成员。SayHello()是一个实例成员,它是不可以在静态类中声明的,所以我们再修改一下,变成下面这样:

    a64e011c0fdfcc0cda85d5894a94b642.png

    静态类 静态方法

    现在编译运行正常了。

    接下来简单介绍几个微软提供给我们的常用静态类

    1. Console类
    2. File类
    3. Environment类

    Console类、Environment类就不说了,来看一下File类几个常用的静态方法:

    public static string ReadAllText(string path)//读取文本文件的所有内容

    public static StreamReader OpenText(string path)//打开一个文本文件返回一个流

    public static StreamWriter CreateText(string path)//创建一个文件返回一个写入流

    public static void Copy(string sourceFileName, string destFileName)//拷贝文件

    public static void WriteAllLines(string path, IEnumerable contents, Encoding encoding)

    //写入所有的行
    public static void WriteAllText(string path, string contents)//写入所有的内容

    public static byte[] ReadAllBytes(string path)//读取所有的字节

    这些静态方法在平时是经常用?用起来是不是很爽?直接用类名File调用静态方法飞起!

    好了,喜欢我的文章的朋友请关注我,我会定期更新,请帮忙转发点赞,谢谢!

    展开全文
  • 设计并发数据结构意味着,多个线程可以并发的访问这个数据结构,线程可对这个数据结构做相同或不同的操作,并且每一个线程都能在自己域看到该数据结构。多线程环境下,无数据丢失和损毁,所有的数据需要维持原样,...

    1 为并发设计的意义何在?

    设计并发数据结构意味着,多个线程可以并发的访问这个数据结构,线程可对这个数据结构做相同或不同的操作,并且每一个线程都能在自己域中看到该数据结构。多线程环境下,无数据丢失和损毁,所有的数据需要维持原样,且无条件竞争。这样的数据结构,称之为“线程安全”的数据结构。通常情况下,当多个线程对数据结构进行并发操作是安全的,但不同操作则需要单线程独立访问数据结构;或相反,当线程执行不同的操作时,对同一数据结构的并发操作是安全的,而多线程执行同样的操作,则会出现问题。

    实际的设计意义并不止上面提到的那样:要为线程提供并发访问数据结构的机会。本质上,是使用互斥量提供互斥特性:在互斥量的保护下,同一时间内只有一个线程可以获取互斥锁。互斥量为了保护数据,显式的阻止了线程对数据结构的并发访问。

    这称为串行化(serialzation):线程轮流访问被保护的数据。这是对数据进行串行的访问,而非并发。因此,需要对数据结构的设计仔细斟酌,确保能真正的并发访问。虽然,有些数据结构有着比其他数据结构多的并发访问范围,但是在所有情况下的思路都是一样的:减少保护区域,减少序列化操作,就能提升并发访问的能力。

    进行数据结构的设计之前,让我们快速的浏览一下并发设计中的指导建议。

    1.1 数据结构并发设计的指导与建议(指南)

    设计并发数据结构时,需要考量两方面:一是确保访问安全,二是真正并发访问。第3章的时,已经对如何保证数据结构是线程安全的做过简单的描述:

    • 确保无线程能够看到修改数据结构的“不变量”时的状态。
    • 小心会引起条件竞争的接口,提供完整操作的函数,而非操作步骤。
    • 注意数据结构的行为是否会产生异常,从而确保“不变量”的状态。
    • 将死锁的概率降到最低。使用数据结构时,需要限制锁的范围,避免嵌套锁的存在。

    思考设计细节前,还需要考虑数据结构对于使用者来说有什么限制;当线程通过特殊的函数对数据结构进行访问时,还有哪些函数能被其他的线程安全调用呢?

    这是一个很重要的问题,普通的构造函数和析构函数需要独立访问数据结构,所以用户在使用的时候,就不能在构造函数完成前或析构函数完成后,对数据结构进行访问。当数据结构支持赋值操作swap()或拷贝构造时,作为数据结构的设计者,即使数据结构中有大量的函数被线程所操纵,也需要保证这些操作在并发环境下是安全的(或确保这些操作能够独立访问),以保证并发访问时不会出现错误。

    第二个方面确保真正的并发访问。这里没有更多的指导意见。不过,作为一个数据结构的设计者,设计数据结构时考虑以下问题:

    • 在锁范围中进行操作,是否允许在锁外执行?
    • 数据结构中不同的区域能是否被不同的互斥量所保护?
    • 所有操作都需要同级互斥量保护吗?
    • 能否对数据结构进行简单的修改,以增加并发访问的概率,且不影响操作语义?

    这些问题都源于一个指导思想:如何让序列化访问最小化,让真实并发最大化?允许线程并发读取的数据结构并不少见,而对数据结构的修改,必须是单线程独立访问,这种结构类似于std::shared_mutex。同样的,这种数据结构也很常见——支持在多线程执行不同的操作时,并能串行执行相同的操作的线程(你很快就能看到)。

    最简单的线程安全结构,通常对数据进行保护使用的是互斥量和锁。虽然,这么做还有问题(如同在第3中提到的那样),不过这样相对简单,且保证只有一个线程在同一时间对数据结构进行一次访问。为了让你轻松的设计线程安全的数据结构,接下来了解一下基于锁的数据结构,以及第7章将提到的无锁并发数据结构的设计。

    2 基于锁的并发数据结构

    基于锁的并发数据结构,需要确保访问线程持有锁的时间最短,对于只有一个互斥量的数据结构来说,这十分困难。需要保证数据不被锁之外的操作所访问,并且还要保证不会在结构上产生条件竞争(如第3章所述)。使用多个互斥量来保护数据结构中不同的区域时,问题会暴露的更加明显,当操作需要获取多个互斥锁时,就有可能产生死锁。所以在设计时,使用多个互斥量时需要格外小心。

    在本节中,你将使用6.1.1节中的指导建议,来设计一些简单的数据结构——使用互斥量和锁的方式来保护数据。每一个例子中,都是在保证数据结构是线程安全的前提下,对数据结构并发访问的概率(机会)进行提高。

    我们先来看看在第3章中栈的实现,这个实现就是一个十分简单的数据结构,它只使用了一个互斥量。但是,这个结构是线程安全的吗?它离真正的并发访问又有多远呢?

    2.1 线程安全栈——使用锁

    线程安全栈的类定义

    #include struct empty_stack: std::exception{  const char* what() const throw();};templateclass threadsafe_stack{private:  std::stack data;  mutable std::mutex m;public:  threadsafe_stack(){}  threadsafe_stack(const threadsafe_stack& other)  {    std::lock_guard<:mutex> lock(other.m);    data=other.data;  }  threadsafe_stack& operator=(const threadsafe_stack&) = delete;  void push(T new_value)  {    std::lock_guard<:mutex> lock(m);    data.push(std::move(new_value));  // 1  }  std::shared_ptr pop()  {    std::lock_guard<:mutex> lock(m);    if(data.empty()) throw empty_stack();  // 2    std::shared_ptr const res(      std::make_shared(std::move(data.top())));  // 3    data.pop();  // 4    return res;  }  void pop(T& value)  {    std::lock_guard<:mutex> lock(m);    if(data.empty()) throw empty_stack();    value=std::move(data.top());  // 5    data.pop();  // 6  }  bool empty() const  {    std::lock_guard<:mutex> lock(m);    return data.empty();  }};

    来看看是如何应用指导意见的。

    首先,互斥量m可保证线程安全,就是对每个成员函数进行加锁保护。保证在同一时间内,只有一个线程可以访问到数据,所以能够保证修改数据结构的“不变量”时,不会被其他线程看到。

    其次,在empty()和pop()成员函数之间会存在竞争,不过代码会在pop()函数上锁时,显式的查询栈是否为空,所以这里的竞争不是恶性的。pop()直接返回弹出值,就可避免std::stack<>中top()和pop()两成员函数之间的潜在竞争。

    再次,类中也有一些异常源。对互斥量上锁可能会抛出异常,因为上锁操作是每个成员函数所做的第一个操作,所以这是极其罕见的(这意味这问题不在锁上,就是在系统资源上)。因无数据修改,所以是安全的。因解锁一个互斥量是不会失败的,所以段代码很安全,并且使用std::lock_guard<>也能保证互斥量上锁的状态。

    对data.push()①的调用可能会抛出一个异常,不是拷贝/移动数据值,就是内存不足。不管是哪种情况,std::stack<>都能保证其安全性,所以这里也没有问题。

    第一个重载的pop()中,代码可能会抛出一个empty_stack的异常②,不过数据没有被修改,所以是安全的。对于res的创建③,也可能会抛出一个异常,有两方面的原因:对std::make_shared的调用,可能无法分配出足够的内存去创建新的对象,并且内部数据需要对新对象进行引用;或者在拷贝或移动构造到新分配的内存中返回时抛出异常。两种情况下,C++运行库和标准库能确保不会出现内存泄露,并且新创建的对象(如果有的话)都能被正确销毁。因为没有对栈进行任何修改,所以也不会有问题。当调用data.pop()④时,能确保不抛出异常并返回结果,所以这个重载pop()函数是“异常-安全”的。

    第二个重载pop()除了在拷贝赋值或移动赋值时会抛出异常⑤,当构造新对象和std::shared_ptr实例时都不会抛出异常。同样,调用data.pop()⑥(这个成员函数保证不会抛出异常)之前,依旧没有对数据结构进行修改,所以这个函数也为“异常-安全”。

    最后,empty()不会修改任何数据,所以也是“异常-安全”函数。

    当调用持有一个锁的用户代码时,有两个地方可能会死锁:拷贝构造或移动构造(①,③)和拷贝赋值或移动赋值操作⑤;还有一个潜在死锁的地方在于用户定义的new操作符。无论是以直接调用栈的成员函数的方式,还是在成员函数进行操作时,对已经插入或删除的数据进行操作的方式,对锁进行获取,都可能造成死锁。不过,用户要对栈负责,当栈未对数据进行拷贝或分配时,用户就不能随意的将其添加到栈中。

    所有成员函数都使用std::lock_guard<>保护数据,所以栈成员函数才是“线程安全”的。当然,构造与析构函数不是“线程安全”的,不过也没关系,因为构造与析构只有一次。调用一个不完全构造对象或是已销毁对象的成员函数,无论在那种编程方式下都不可取。所以,用户就要保证在栈对象完成构建前,其他线程无法对其进行访问;并且,一定要保证在栈对象销毁后,所有线程都要停止对其进行访问。

    即使在多线程下,并发调用的成员函数也是安全的(因为使用锁)。同时,要保证在单线程的情况下,数据结构做出正确反应。序列化线程会隐性的限制程序性能,这就是栈争议声最大的地方:当一个线程在等待锁时,就会无所事事。对于栈来说,等待添加元素也是没有意义的,所以当线程需要等待时,会定期检查empty()或pop(),以及对empty_stack异常进行关注。这样的现实会限制栈的实现方式,线程等待时会浪费宝贵的资源去检查数据,或要求用户编写外部等待和提示的代码(例如:使用条件变量),这就使内部锁失去存在的意义——也就造成资源的浪费。第4章中的队列,就是使用条件内部变量进行等待的数据结构,接下来我们就来了解一下。

    2.2 线程安全队列——使用锁和条件变量

    2.1.1使用条件变量实现的线程安全队列

    templateclass threadsafe_queue{private:  mutable std::mutex mut;  std::queue data_queue;  std::condition_variable data_cond;public:  threadsafe_queue()  {}  void push(T data)  {    std::lock_guard<:mutex> lk(mut);    data_queue.push(std::move(data));    data_cond.notify_one();  // 1  }  void wait_and_pop(T& value)  // 2  {    std::unique_lock<:mutex> lk(mut);    data_cond.wait(lk,[this]{return !data_queue.empty();});    value=std::move(data_queue.front());    data_queue.pop();  }  std::shared_ptr wait_and_pop()  // 3  {    std::unique_lock<:mutex> lk(mut);    data_cond.wait(lk,[this]{return !data_queue.empty();});  // 4    std::shared_ptr res(      std::make_shared(std::move(data_queue.front())));    data_queue.pop();    return res;  }  bool try_pop(T& value)  {    std::lock_guard<:mutex> lk(mut);    if(data_queue.empty())      return false;    value=std::move(data_queue.front());    data_queue.pop();    return true;  }  std::shared_ptr try_pop()  {    std::lock_guard<:mutex> lk(mut);    if(data_queue.empty())      return std::shared_ptr();  // 5    std::shared_ptr res(      std::make_shared(std::move(data_queue.front())));    data_queue.pop();    return res;  }  bool empty() const  {    std::lock_guard<:mutex> lk(mut);    return data_queue.empty();  }};

    除了在push()①中调用data_cond.notify_one(),以及wait_and_pop()②③,2.2.1中对队列的实现与2.1中对栈的实现类似。两个重载try_pop()除了在队列为空时抛出异常,其他的与2.1中pop()函数完全一样。不同的是,2.1中对值的检索会返回一个bool值,而2.2.1中当指针指向空值的时候会返回NULL指针⑤,这也是实现栈的一个有效方式。所以,即使排除掉wait_and_pop()函数,之前对栈的分析依旧适用于这里。

    wiat_and_pop()函数是等待队列向栈进行输入的一个解决方案;比起持续调用empty(),等待线程调用wait_and_pop()函数和条件变量的方式要好很多。对于data_cond.wait()的调用,直到队列中有一个元素的时候才会返回,所以不用担心会出现一个空队列的情况,且数据会一直被互斥锁保护。因为不变量并未发生变化,所以函数不会增加新的条件竞争或死锁的可能。

    异常安全会有一些变化,不止一个线程等待对队列进行推送操作时,只会有一个线程因data_cond.notify_one()而继续工作着。但是,如果这个工作线程在wait_and_pop()中抛出一个异常,例如:构造新的std::shared_ptr<>对象④时抛出异常,那么其他线程则会永世长眠。这种情况是不可接受的,所以调用函数就需要改成data_cond.notify_all(),这个函数将唤醒所有的工作线程,不过当大多线程发现队列依旧是空时,又会耗费很多资源让线程重新进入睡眠状态。第二种替代方案是,有异常抛出的时,让wait_and_pop()函数调用notify_one(),从而让个另一个线程可以去尝试索引存储的值。第三种替代方案是,将std::shared_ptr<>的初始化过程移到push()中,并且存储std::shared_ptr<>实例,而不是直接使用数据的值。将std::shared_ptr<>拷贝到内部std::queue<>中就不会抛出异常了,这样wait_and_pop()又是安全的了。下面的程序清单,就是根据第三种方案进行修改的。

    2.2.2 持有std::shared_ptr<>实例的线程安全队列

    templateclass threadsafe_queue{private:  mutable std::mutex mut;  std::queue<:shared_ptr> > data_queue;  std::condition_variable data_cond;public:  threadsafe_queue()  {}  void wait_and_pop(T& value)  {    std::unique_lock<:mutex> lk(mut);    data_cond.wait(lk,[this]{return !data_queue.empty();});    value=std::move(*data_queue.front());  // 1    data_queue.pop();  }  bool try_pop(T& value)  {    std::lock_guard<:mutex> lk(mut);    if(data_queue.empty())      return false;    value=std::move(*data_queue.front());  // 2    data_queue.pop();    return true;  }  std::shared_ptr wait_and_pop()  {    std::unique_lock<:mutex> lk(mut);    data_cond.wait(lk,[this]{return !data_queue.empty();});    std::shared_ptr res=data_queue.front();  // 3    data_queue.pop();    return res;  }  std::shared_ptr try_pop()  {    std::lock_guard<:mutex> lk(mut);    if(data_queue.empty())      return std::shared_ptr();    std::shared_ptr res=data_queue.front();  // 4    data_queue.pop();    return res;  }  void push(T new_value)  {    std::shared_ptr data(    std::make_shared(std::move(new_value)));  // 5    std::lock_guard<:mutex> lk(mut);    data_queue.push(data);    data_cond.notify_one();  }  bool empty() const  {    std::lock_guard<:mutex> lk(mut);    return data_queue.empty();  }};

    为让std::shared_ptr<>持有数据的结果显而易见:弹出函数会持有一个变量的引用,为了接收这个新值,必须对存储的指针进行解引用①②;并且,返回调用函数前,弹出函数都会返回一个std::shared_ptr<>实例,实例可以在队列中检索③④。

    std::shared_ptr<>持有数据的好处:新实例分配结束时,不会被锁在push()⑤当中(而在2.2.1中,只能在pop()持有锁时完成)。因为内存分配需要在性能上付出很高的代价(性能较低),所以使用std::shared_ptr<>对队列的性能有很大的提升,其减少了互斥量持有的时间,允许其他线程在分配内存的同时,对队列进行其他的操作。

    如同栈的例子,使用互斥量保护整个数据结构,不过会限制队列对并发的支持;虽然,多线程可能被队列中的各种成员函数所阻塞,但仍有一个线程能在任意时间内进行工作。不过,这种限制是因为在实现中使用了std::queue<>;因为使用标准容器的原因,数据处于保护中。要对数据结构实现进行具体的控制,需要提供更多细粒度锁,来完成更高级的并发。

    2.3 线程安全队列——使用细粒度锁和条件变量

    2.2.1和2.2.2中,使用一个互斥量对一个数据队列(data_queue)进行保护。为了使用细粒度锁,需要看一下队列内部的组成结构,并且将一个互斥量与每个数据相关联。

    最简单的队列就是单链表了,就如图6.1那样。队列里包含一个头指针,其指向链表中的第一个元素,并且每一个元素都会指向下一个元素。从队列中删除数据,其实就是将头指针指向下一个元素,并将之前头指针指向的值进行返回。

    向队列中添加元素是要从结尾进行的。为了做到这点,队列里还有一个尾指针,其指向链表中的最后一个元素。新节点的加入将会改变尾指针的next指针,之前最后一个元素将会指向新添加进来的元素,新添加进来的元素的next将会使新的尾指针。当链表为空时,头/尾指针皆为NULL。

    f384c0388a3a6fcb729ccfe72093c412.png

    图1 用单链表表示的队列

    下面的代码,是一个简单队列的实现,基于清单2.2.1代码的精简版本;这个队列仅供单线程使用,所以实现中只有一个try_pop()函数;并且,没有wait_and_pop()函数。

    2.3.1队列实现——单线程版

    templateclass queue{private:  struct node  {    T data;    std::unique_ptr next;    node(T data_):    data(std::move(data_))    {}  };  std::unique_ptr head;  // 1  node* tail;  // 2public:  queue()  {}  queue(const queue& other)=delete;  queue& operator=(const queue& other)=delete;  std::shared_ptr try_pop()  {    if(!head)    {      return std::shared_ptr();    }    std::shared_ptr const res(      std::make_shared(std::move(head->data)));    std::unique_ptr const old_head=std::move(head);    head=std::move(old_head->next);  // 3    return res;  }  void push(T new_value)  {    std::unique_ptr p(new node(std::move(new_value)));    node* const new_tail=p.get();    if(tail)    {      tail->next=std::move(p);  // 4    }    else    {      head=std::move(p);  // 5    }    tail=new_tail;  // 6  }};

    首先,注意2.3.1中使用了std::unique_ptr来管理节点,因为其能保证节点(其引用数据的值)在删除时候,不需要使用delete操作显式删除。这样的关系链表,管理着从头结点到尾节点的每一个原始指针,就需要std::unique_ptr类型的结点引用。

    虽然,这种实现对于单线程来说没什么问题,但当在多线程下尝试使用细粒度锁时,就会出现问题。因为在给定的实现中有两个数据项(head①和tail②);即使,使用两个互斥量来保护头指针和尾指针,也会出现问题。

    最明显的问题就是push()可以同时修改头指针⑤和尾指针⑥,所以push()函数会同时获取两个互斥量。虽然会将两个互斥量都上锁,但这问题还不算太糟糕。糟糕的是push()和pop()都能访问next指针指向的节点:push()可更新tail->next④,随后try_pop()读取read->next③。当队列中只有一个元素时,head==tail,所以head->next和tail->next是同一个对象,并且这个对象需要保护。不过,“在同一个对象在未被head和tail同时访问时,push()和try_pop()锁住的是同一个锁”就不对了。所以,就没有比上面实现更好的选择了。这里会“柳暗花明又一村”吗?

    通过分离数据实现并发

    可以使用“预分配一个虚拟节点(无数据),确保这个节点永远在队列的最后,用来分离头尾指针能访问的节点”的办法,走出这个困境。对于一个空队列来说,head和tail都属于虚拟指针,而非空指针。这个办法挺好,因为当队列为空时,try_pop()不能访问head->next了。当添加一个节点入队列时(这时有真实节点了),head和tail现在指向不同的节点,所以就不会在head->next和tail->next上产生竞争。这里的缺点是,必须额外添加一个间接层次的指针数据,来做虚拟节点。下面的代码描述了这个方案如何实现。

    2.3.2 带有虚拟节点的队列

    templateclass queue{private:  struct node  {    std::shared_ptr data;  // 1    std::unique_ptr next;  };  std::unique_ptr head;  node* tail;public:  queue():    head(new node),tail(head.get())  // 2  {}  queue(const queue& other)=delete;  queue& operator=(const queue& other)=delete;  std::shared_ptr try_pop()  {    if(head.get()==tail)  // 3    {      return std::shared_ptr();    }    std::shared_ptr const res(head->data);  // 4    std::unique_ptr old_head=std::move(head);    head=std::move(old_head->next);  // 5    return res;  // 6  }  void push(T new_value)  {    std::shared_ptr new_data(      std::make_shared(std::move(new_value)));  // 7    std::unique_ptr p(new node);  //8    tail->data=new_data;  // 9    node* const new_tail=p.get();    tail->next=std::move(p);    tail=new_tail;  }};

    try_pop()不需要太多的修改。首先,你可以拿head和tail③进行比较,这就要比检查指针是否为空的好,因为虚拟节点意味着head不可能是空指针。head是一个std::unique_ptr对象,需要使用head.get()来做比较。其次,因为node现在存在数据指针中①,就可以对指针进行直接检索④,而非构造一个T类型的新实例。push()函数的改动最大:首先,必须在堆上创建一个T类型的实例,并且让其与一个std::shared_ptr<>对象相关联⑦(节点使用std::make_shared就是为了避免内存二次分配,避免增加引用次数)。创建的新节点就成为了虚拟节点,所以不需要为new_value提供构造函数⑧。这里反而需要将new_value的副本赋给之前的虚拟节点⑨。最终,为了让虚拟节点存在于队列中,需要使用构造函数来创建它②。

    现在,我确信你会对如何修改队列让其变成一个线程安全的队列感到惊讶。好吧,现在的push()只能访问tail,而不能访问head,这就是一个可以访问head和tail的try_pop(),但是tail只需在最初进行比较,所以所存在的时间很短。重大的提升在于虚拟节点意味着try_pop()和push()不能对同一节点进行操作,所以就不再需要互斥了。那么,只需要使用一个互斥量来保护head和tail就够了。那么,现在应该锁哪里?

    我们的是为了最大程度的并发化,所以需要上锁的时间尽可能的少。push()很简单:互斥量需要对tail的访问上锁,就需要对每一个新分配的节点进行上锁⑧,还有对当前尾节点进行赋值的时候⑨也需要上锁。锁需要持续到函数结束时才能解开。

    try_pop()就不简单了。首先,需要使用互斥量锁住head,一直到head弹出。实际上,互斥量决定了哪一个线程进行弹出操作。一旦head被改变⑤,才能解锁互斥量;当在返回结果时,互斥量就不需要进行上锁了⑥,这使得访问tail需要一个尾互斥量。因为,只需要访问tail一次,且只有在访问时才需要互斥量。这个操作最好是通过函数进行包装。事实上,因为代码只有在成员需要head时,互斥量才上锁,这项也需要包含在包装函数中。最终代码如下所示。

    2.3.2 线程安全队列——细粒度锁版

    templateclass threadsafe_queue{private:  struct node  {    std::shared_ptr data;    std::unique_ptr next;  };  std::mutex head_mutex;  std::unique_ptr head;  std::mutex tail_mutex;  node* tail;  node* get_tail()  {    std::lock_guard<:mutex> tail_lock(tail_mutex);    return tail;  }  std::unique_ptr pop_head()  {    std::lock_guard<:mutex> head_lock(head_mutex);    if(head.get()==get_tail())    {      return nullptr;    }    std::unique_ptr old_head=std::move(head);    head=std::move(old_head->next);    return old_head;  }public:  threadsafe_queue():  head(new node),tail(head.get())  {}  threadsafe_queue(const threadsafe_queue& other)=delete;  threadsafe_queue& operator=(const threadsafe_queue& other)=delete;  std::shared_ptr try_pop()  {     std::unique_ptr old_head=pop_head();     return old_head?old_head->data:std::shared_ptr();  }  void push(T new_value)  {    std::shared_ptr new_data(      std::make_shared(std::move(new_value)));    std::unique_ptr p(new node);    node* const new_tail=p.get();    std::lock_guard<:mutex> tail_lock(tail_mutex);    tail->data=new_data;    tail->next=std::move(p);    tail=new_tail;  }};

    让我们用挑剔的目光来看一下上面的代码,并考虑1.1节中给出的指导意见。观察不变量前,需要确定的状态有:

    • tail->next == nullptr
    • tail->data == nullptr
    • head == taill(意味着空列表)
    • 单元素列表 head->next = tail
    • 列表中的每一个节点x,x!=tail且x->data指向一个T类型的实例,并且x->next指向列表中下一个节点。x->next == tail意味着x就是列表中最后一个节点
    • 顺着head的next节点找下去,最终会找到tail

    这里的push()很简单:仅修改了被tail_mutex的数据,因为新的尾节点是一个空节点,并且其data和next都为旧的尾节点(实际上的尾节点)设置好,所以其能维持不变量的状态。

    有趣的部分在于try_pop()上,不仅需要对tail_mutex上锁来保护对tail的读取;还要保证在从头读取数据时,不会产生数据竞争。如果没有这些互斥量,当一个线程调用try_pop()的同时,另一个线程调用push(),这里操作顺序将不可预测。尽管,每一个成员函数都持有一个互斥量,这些互斥量保护的数据不会同时被多个线程访问到;并且,队列中的所有数据来源,都是通过调用push()得到。线程可能会无序的访问同一数据地址,就会有数据竞争,以及未定义行为。幸运的是,get_tail()中的tail_mutex解决了所有的问题。因为调用get_tail()将会锁住同名锁,就像push()一样,这就为两个操作规定好了顺序。要不就是get_tail()在push()之前被调用,线程可以看到旧的尾节点,要不就是在push()之后完成,线程就能看到tail的新值,以及真正tail的值,并且新值会附加到之前的tail值上。

    当get_tail()调用前head_mutex已经上锁,这一步也是很重要。如果不这样,调用pop_head()时就会被get_tail()和head_mutex所卡住,因为其他线程调用try_pop()(以及pop_head())时,都需要先获取锁,然后阻止从下面的过程中初始化线程:

    std::unique_ptr pop_head() // 这是个有缺陷的实现{  node* const old_tail=get_tail();  // 1 在head_mutex范围外获取旧尾节点的值  std::lock_guard<:mutex> head_lock(head_mutex);  if(head.get()==old_tail)  // 2  {    return nullptr;  }  std::unique_ptr old_head=std::move(head);  head=std::move(old_head->next);  // 3  return old_head;}

    这是一个有缺陷的实现,在锁的范围之外调用get_tail(),在初始化线程并获取head_mutex时,可能也许会发现head和tail发生了改变。并且,不只是返回尾节点时,返回的并不是尾节点的值,其值甚至都不列表中的值了。即使head是最后一个节点,也就意味着访问head和old_tail②失败了。因此,当更新head③时,可能会将head移到tail之后,这样的话数据结构就遭到了破坏。正确实现中(2.3.2),需要保证在head_mutex保护的范围内调用get_tail()。就能保证没有其他线程能对head进行修改,并且tail会向正确的方向移动(当有新节点添加进来时),这样就很安全了。head不会传递给get_tail()的返回值,所以不变量的状态是稳定的。

    当使用pop_head()更新head时(从队列中删除节点),互斥量就已经上锁了,并且try_pop()可以提取数据,并在有数据的时候删除一个节点(若没有数据,则返回std::shared_ptr<>的空实例),因为只有一个线程可以访问这个节点,所以这个操作是安全的。

    接下来,外部接口就相当于2.2.1代码中的子集了,同样的分析结果:对于固有接口来说,不存在条件竞争。

    异常是很有趣的东西。虽然,已经改变了数据的分配模式,但是异常可能从别的地方来袭。try_pop()中的对锁的操作会产生异常,并直到获取锁才能对数据进行修改。因此,try_pop()是异常安全的。另一方面,push()可以在堆上新分配出一个T的实例,以及一个node的新实例,这里可能会抛出异常。但是,所有分配的对象都赋给了智能指针,当异常发生时就会被释放掉。一旦获取锁,push()中的操作就不会抛出异常,所以push()也是异常安全的。

    因为没有修改任何接口,所以不会死锁。在实现内部也不会有死锁;唯一需要获取两个锁的是pop_head(),这个函数需要获取head_mutex和tail_mutex,所以不会产生死锁。

    剩下的问题就在于实际并发的可行性上了。这个结构对并发访问的考虑要多于2.2.1中的代码,因为锁粒度更加的小,并且更多的数据不在锁的保护范围内。比如,push()中新节点和新数据的分配都不需要锁来保护。多线程情况下,节点及数据的分配是“安全”并发的。同一时间内,只有一个线程可以将它的节点和数据添加到队列中,所以代码中只是简单使用了指针赋值的形式,相较于基于std::queue<>的实现,这个结构中就不需要对于std::queue<>的内部操作进行上锁这一步。

    同样,try_pop()持有tail_mutex也只有很短的时间,只为保护对tail的读取。因此,当有数据push进队列后,try_pop()几乎及可以完全并发调用。同样在执行中,对head_mutex的持有时间也是极短的。当并发访问时,就会增加对try_pop()的访问次数;且只有一个线程在同一时间内可以访问pop_head(),且多线程情况下可以删除队列中的旧节点,并且安全的返回数据。

    等待数据弹出

    OK,所以2.3.2提供了一个使用细粒度锁的线程安全队列,不过只有try_pop()可以并发访问(且只有一个重载存在)。在2.2.1中的wait_and_pop()呢?能通过细粒度锁实现相同功能的接口吗?

    答案是“是的”,不过的确有些困难,困难在哪里?修改push()是相对简单的:只需要在函数末尾添加data_cond.notify_ont()函数的调用即可(如同2.2.1中那样)。当然,事实并没有那么简单:使用细粒度锁,是为了保证最大程度的并发。当互斥量和notify_one()混用的时,如果被通知的线程在互斥量解锁后被唤醒,那么这个线程就需要等待互斥量上锁。另一方面,解锁操作在notify_one()之前调用时,互斥量可能会等待线程醒来获取互斥锁(假设没有其他线程对互斥量上锁)。这可能是一个微小的改动,但是对于一些情况来说就很重要了。

    wait_and_pop()就有些复杂了,因为需要确定函数在哪里执行,并且需要确定哪些互斥量需要上锁。等待的条件是“队列非空”,也就是head!=tail。这样的话,就需要同时获取head_mutex和tail_mutex,并对其进行上锁,不过在2.3.2中已经使用tail_mutex来保护对tail的读取,以及不用和自身比较,所以这种逻辑也适用于这里。如果有函数让head!=get_tail(),只需要持有head_mutex,然后你可以使用锁,对data_cond.wait()的调用进行保护。当等待逻辑添加入结构当中,实现的方式与try_pop()基本上一样了。

    对于try_pop()和wait_and_pop()的重载都需要深思熟虑。将返回std::shared_ptr<>替换为从“old_head后索引出的值,并且拷贝赋值给value参数”进行返回时,将会存在异常安全问题。数据项在互斥锁未上锁的情况下被删除,剩下的数据返回给调用者。不过,拷贝赋值抛出异常(可能性很大)时,数据项将会丢失,因为它没有返回到队列原来的位置上。

    当T类型有无异常抛出的移动赋值操作,或无异常抛出的交换操作时可以使用它,不过有更通用的解决方案,无论T是什么类型这个方案都能使用。节点从列表中删除前,就需要将有可能抛出异常的代码,放在锁保护的范围内来保证异常安全性。也就是需要对pop_head()进行重载,查找索引值在列表改动前的位置。

    相比之下,empty()就简单了:只需要锁住head_mutex,并且检查head==get_tail()(详见清单6.10)就可以了。最终的代码,在2.3.3,2.3.4,2.3.5和2.3.6中。

    2.3.3 可上锁和等待的线程安全队列——内部机构及接口

    templateclass threadsafe_queue{private:  struct node  {    std::shared_ptr data;    std::unique_ptr next;  };  std::mutex head_mutex;  std::unique_ptr head;  std::mutex tail_mutex;  node* tail;  std::condition_variable data_cond;public:  threadsafe_queue():    head(new node),tail(head.get())  {}  threadsafe_queue(const threadsafe_queue& other)=delete;  threadsafe_queue& operator=(const threadsafe_queue& other)=delete;  std::shared_ptr try_pop();  bool try_pop(T& value);  std::shared_ptr wait_and_pop();  void wait_and_pop(T& value);  void push(T new_value);  bool empty();};

    向队列中添加新节点是相当简单的——下面的实现与上面的代码差不多。

    2.3.4 可上锁和等待的线程安全队列——推入新节点

    templatevoid threadsafe_queue::push(T new_value){  std::shared_ptr new_data(  std::make_shared(std::move(new_value)));  std::unique_ptr p(new node);  {    std::lock_guard<:mutex> tail_lock(tail_mutex);    tail->data=new_data;    node* const new_tail=p.get();    tail->next=std::move(p);    tail=new_tail;  }  data_cond.notify_one();}

    如同之前所提到的,复杂部分都在pop中,所以提供帮助性函数去简化这部分就很重要了。下一个清单中将展示wait_and_pop()的实现,以及相关的帮助函数。

    2.3.5 可上锁和等待的线程安全队列——wait_and_pop()

    templateclass threadsafe_queue{private:  node* get_tail()  {    std::lock_guard<:mutex> tail_lock(tail_mutex);    return tail;  }  std::unique_ptr pop_head()  // 1  {    std::unique_ptr old_head=std::move(head);    head=std::move(old_head->next);    return old_head;  }  std::unique_lock<:mutex> wait_for_data()  // 2  {    std::unique_lock<:mutex> head_lock(head_mutex);    data_cond.wait(head_lock,[&]{return head.get()!=get_tail();});    return std::move(head_lock);  // 3  }  std::unique_ptr wait_pop_head()  {    std::unique_lock<:mutex> head_lock(wait_for_data());  // 4    return pop_head();  }  std::unique_ptr wait_pop_head(T& value)  {    std::unique_lock<:mutex> head_lock(wait_for_data());  // 5    value=std::move(*head->data);    return pop_head();  }public:  std::shared_ptr wait_and_pop()  {    std::unique_ptr const old_head=wait_pop_head();    return old_head->data;  }  void wait_and_pop(T& value)  {    std::unique_ptr const old_head=wait_pop_head(value);  }};

    2.3.5中所示的pop部分的实现中使用一些帮助函数来降低代码的复杂度,例如:pop_head()①和wait_for_data()②,这些函数分别是删除头结点和等待队列中有数据弹出的结点。wait_for_data()需要特别关注,因为不仅使用Lambda函数对条件变量进行等待,而且还会将锁的实例返回给调用者③。这就需要确保同一个锁在执行与wait_pop_head()重载④⑤的相关操作时,已持有锁。pop_head()是对try_pop()代码的复用,将在下面进行展示:

    2.3.6 可上锁和等待的线程安全队列——try_pop()和empty()

    templateclass threadsafe_queue{private:  std::unique_ptr try_pop_head()  {    std::lock_guard<:mutex> head_lock(head_mutex);    if(head.get()==get_tail())    {      return std::unique_ptr();    }    return pop_head();  }  std::unique_ptr try_pop_head(T& value)  {    std::lock_guard<:mutex> head_lock(head_mutex);    if(head.get()==get_tail())    {      return std::unique_ptr();    }    value=std::move(*head->data);    return pop_head();  }public:  std::shared_ptr try_pop()  {    std::unique_ptr old_head=try_pop_head();    return old_head?old_head->data:std::shared_ptr();  }  bool try_pop(T& value)  {    std::unique_ptr const old_head=try_pop_head(value);    return old_head;  }  bool empty()  {    std::lock_guard<:mutex> head_lock(head_mutex);    return (head.get()==get_tail());  }};

    这是一个无界队列:线程可以持续向队列中添加数据项,即使没有元素被删除。与之相反的就是有界队列,有界队列中队列在创建的时候最大长度就已经是固定的了。当有界队列满载时,尝试在向其添加元素的操作将会失败或者阻塞,直到有元素从队列中弹出。执行任务时,有界队列对于减少线程间的开销是很有帮助的。其会阻止线程对队列进行填充,并且可以避免线程从较远的地方对数据项进行索引。

    无界队列很容易扩展成可在push()中等待条件变量的定长队列,相对于等待队列中具有的数据项(pop()执行完成后),需要等待队列中数据项小于最大值就可以了。对于有界队列更多的讨论,已经超出了本书的范围,这里就不再多说;现在越过队列,向更加复杂的数据结构进发。

    需要C/C++ Linux服务器架构师学习资料私信“资料”(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

    展开全文
  • 安装Scrapypip install Scrapy安装mongodbpip install pymongo下面我们做一个简单示例,创建一个名字为BOSS爬虫工程,然后创建一个名字为zhipin爬虫来爬取zhipin.com网站创建工程步骤:1、创建工程 scrapy ...
  • 一个网站能否带来客户咨询量,很大程度在于我们对网站自身的数据是否有清晰的了解。用户搜索什么样子的关键词进入网站,哪些关键词被大量搜索,哪些关键词没有搜索,我们都要做好数据化分析,为将来的微调做基础准备...
  • 孤胆英雄在联盟赛场上难成大事,想要获得胜利必须需要整个球队...詹姆斯出道至今最独的一个赛季:场均出手21.9次,数据怎样?詹姆斯詹姆斯出现在人们视野时,他总是闪闪发光。2003年,詹姆斯以状元秀身...
  • 怎样修改Java中的final字段? 先说答案:通过反射是可以修改final字段的! ps:但是修改后的数据能不能生效(正确访问到修改后的数据)就得看情况了,不同的数据类型、不同的数据初始化方式、不同的访问方式都可能导致...
  • 怎样利用常成员函数?

    千次阅读 2010-06-22 16:26:00
    <br />(1) 如果在一个类中,有些数据成员的值允许改变,另一些数据成员的值不允许改变,则可以将一部分数据成员声明为const,以保证其值不被改变,可以用非const成员函数引用这些数据成员的值,并修改非...
  • 程序一个类AClass(负责实现数据协议A),实例化对象aClass,在程序会用到AClass静态成员和aClass方法。后来因为数据协议变动,新写了一个类BClass(负责实现数据协议B),BClass和Aclass具有相同的成员变量...
  •  前面说过,派生从基类继承时有三个步骤,第一个步骤是吸收基类成员,吸收了基类除构造函数和析构函数之外所有数据成员和函数成员,第二个步骤就是修改基类成员,包括修改对基类成员访问属性和覆盖基类成员...
  • 定义一个类,Point, 具有x, y轴坐标两个私有数据成员(float类型); b.具有获取和设置x,y值公有函数;写出两种构造函数;其原型为: Point();//此时初始化x,y为0 Point(float xx,float yy); c.具有计算与另一个...
  • 为自己的类实现接口

    千次阅读 2007-07-17 23:57:00
    但C++语法要求必须将数据成员和方法都放在一个类的定义,这样就会将类一些内部细节暴露给它用户。使用接口类好处是可以让用户只需要知道类具有哪些功能而不用管它内部怎样实现。再则,如果你写类供...
  • 2.4 怎样表示一个算法 24 2.4.1 用自然语言表示算法 24 2.4.2 用流程图表示算法 24 2.4.3 三种基本结构和改进流程图 28 2.4.4 用N-S 流程图表示算法 29 2.4.5 用伪代码表示算法 30 2.4.6 用计算机语言表示算法 31 ...
  • 2.4 怎样表示一个算法 24 2.4.1 用自然语言表示算法 24 2.4.2 用流程图表示算法 24 2.4.3 三种基本结构和改进流程图 28 2.4.4 用N-S 流程图表示算法 29 2.4.5 用伪代码表示算法 30 2.4.6 用计算机语言表示算法 31 ...
  • 用来更方便地调整修改ggplot2图形中的图元,ggplotGrob()会返回一个gtable,这个对象可以利用gtable包中提供的函数和接口进行操作。, 所有“模板”类型的图形函数,比如plotmatrix(),ggorder()等等,已被标记为...
  • c++ primer 3rd 中文

    2009-12-08 11:04:35
    的类class 机制嵌套类型以及重载函数解析机制也许更重要一个覆盖面非 常广阔库现在成了标准C++一部分其中包括以前称为STL 标准模板库内容新 string 类型一组顺序和关联容器类型比如vector list map ...
  • 的类class 机制嵌套类型以及重载函数解析机制也许更重要一个覆盖面非 常广阔库现在成了标准C++一部分其中包括以前称为STL 标准模板库内容新 string 类型一组顺序和关联容器类型比如vector list ...
  • 怎样创建一个对象;对基本数据类型和数组的一个介绍;作用域以及垃圾收集器清除对象的方式;如何将Java中的所有东西都归为一种新数据类型(),以及如何创建自己的;函数、自变量以及返回值;名字的可见度以及...
  • ◆如何使用控件中的数据录入的控制属性◆ 23 ◆哪些函数是文档、视图相互处理用的◆ 23 ◆如何建立一个基于对话框一程序◆ 23 ◆如何建立一个线程◆ 24 ◆如何让窗口产生一个图标(从应用程序资源中取出)◆ 24 ◆...
  • Accelerated C++ PDF 英文版

    热门讨论 2012-08-16 10:44:37
    除了讲解基础知识以外,这两部分还有另外一个重要意图。标准库设施本身是用C++编写抽象数据类型,定义标准库所使用是任何C++程序员都能使用构造类的语言特征。我们教授C++经验说明,一开始就使用设计良好...
  • 构建高质量C#代码 完整扫描版

    热门讨论 2014-06-04 12:24:48
    14.2 在类中重载运算符 第15章 资源同步与自动清理 15.1 多线程 15.2 易失域(volatile field) 15.3 lock语句 15.4 using语句 第16章 关于C#其他主题 16.1 预处理 16.1.1 根据条件编译代码 16.1.2 发布警告或...
  • 用Delphi实现Word文件预览

    热门讨论 2005-07-13 15:37:57
    如果是按真正面向对象方法,应该定义一个类代表一个子图形,可能是作为 BufferedImage 一个子类,但由于我们是在探索使用 BufferedImage 对象技巧,因此用一个 createSprite() 方法来画出 BufferedImage 对象...
  • 18.6 一个用户管理系统实例 326 18.6.1 配置数据存储区 326 18.6.2 配置安全选项 328 18.6.3 建立业务对象 331 18.6.4 首页设计 333 18.6.5 登录页设计 337 18.6.6 用户注册页设计 338 18.6.7 修改密码页...
  • 超爽自学课件(java)

    2007-09-16 16:04:04
    1) 第1章:对象入门 这一章是对面向对象程序设计(OOP)的一个综述,其中包括对“什么是对象”之类的基本问题回答,并讲述了接口与实现、抽象与封装、消息与函数、继承与合成以及非常重要多形性概念。...

空空如也

空空如也

1 2 3 4 5
收藏数 91
精华内容 36
关键字:

怎样修改一个类中的数据成员