封装就是一个包装,将包装的内外分为两个空间

 

引言

在面向对象三要素中,封装特性为程序设计提供了系统与系统、模块与模块、类与类之间交互的实现手段。封装为软件设计与开发带来前所未有的革命,成为构成面向对象技术最为重要的基础之一。在.NET中,一切看起来都已经被包装在.NET Framework这一复杂的网络中,提供给最终开发人员的是成千上万的类型、方法和接口,而Framework内部一切已经做好了封装。例如,如果你想对文件进行必要的操作,那么使用System.IO.File基本就能够满足多变的需求,因为.NET Framwork已经把对文件的重要操作都封装在System.IO.File等一些基本类中,用户不需要关心具体的实现。

ATM告诉你,什么是封装

那么,封装究竟是什么?

首先,我们考察一个常见的生活实例来进行说明,例如每当发工资的日子小王都来到ATM机前,用工资卡取走一笔钱为女朋友买礼物,从这个很帅的动作,可以得出以下的结论:

小王和ATM机之间,以银行卡进行交互。要取钱,请交卡。

小王并不知道ATM机将钱放在什么地方,取款机如何计算钱款,又如何通过银行卡返回小王所要数目的钱。对小王来说,ATM就是一个黑匣子,只能等着取钱;而对银行来说,ATM机就像银行自己的一份子,是安全、可靠、健壮的员工。

小王要想取到自己的钱,必须遵守ATM机的对外约定。他的任何违反约定的行为都被视为不轨,例如欲以砖头砸开取钱,用公交卡冒名取钱,盗卡取钱都将面临法律风险,所以小王只能安分守己地过着月光族的日子。

那么小王和ATM机的故事,能给我们什么样的启示?对应上面的3条结论,我们的分析如下:

小王以工资卡和ATM机交互信息,ATM机的入卡口就是ATM机提供的对外接口,砖头是塞不进去的,公交卡放进去也没有用。

—  ATM机在内部完成身份验证、余额查询、计算取款等各项服务,具体的操作对用户小王是不可见的,对银行来说这种封闭的操作带来了安全性和可靠性保障。

小王和ATM机之间遵守了银行规定、国家法律这样的协约。这些协约和法律,就挂在ATM机旁边的墙上。

结合前面的示例,再来分析封装吧。具体来说,封装隐藏了类内部的具体实现细节,对外则提供统一访问接口,来操作内部数据成员。这样实现的好处是实现了UI分离,程序员不需要知道类内部的具体实现,只需按照接口协议进行控制即可。同时对类内部来说,封装保证了类内部成员的安全性和可靠性。在上例中,ATM机可以看做封装了各种取款操作的类,取款、验证的操作对类ATM来说,都在内部完成。而ATM类还提供了与小王交互的统一接口,并以文档形式——法律法规,规定了接口的规范与协定来保证服务的正常运行。以面向对象的语言来表达,类似于下面的样子:

namespace InsideDotNet.OOThink.Encapsulation

{

    /// <summary>

    /// ATM

    /// </summary>

    public class ATM

    {

        #region 定义私有方法,隐藏具体实现

       

        private Client GetUser(string  userID) {}

        private bool IsValidUser(Client user) {}

        private int GetCash(int money) {}

        #endregion

        #region 定义公有方法,提供对外接口

        public void CashProcess(string userID, int money)

        {

            Client tmpUser = GetUser(userID);

            if (IsValidUser(tmpUser))

            {

                GetCash(money);

            }

            else

            {

                Console.Write("你不是合法用户,是不是想被发配南极?");

            }

        }

        #endregion

    }

    /// <summary>

    /// 用户类

    /// </summary>

    public class Client

    {

    }

}

.NET应用中,Framework封装了你能想到的各种常见的操作,就像微软提供给我们一个又一个功能不同的ATM机一样,而程序员手中筹码就是根据.NET规范进行开发,是否能取出自己的钱,要看你的卡是否合法。

那么,如果你是银行的主管,又该如何设计自己的ATM呢?该以什么样的技术来保证自己的ATM在内部隐藏实现,对外提供接口呢?

秘密何处:字段、属性和方法

字段、属性和方法,是面向对象的基本概念之一,其基本的概念介绍不是本书的范畴,任何一本关于语言和面向对象的著作中都有相关的详细解释。本书关注的是在类设计之初应该基于什么样的思路,来实现类的功能要求与交互要求?每个设计者,是以什么角度来完成对类架构的设计与规划呢?在我看来,下面的问题是应该首先被列入讨论的选项:

类的功能是什么?

哪些是字段,哪些是属性,哪些是方法?

对外提供的公有方法有哪些,对内隐藏的私有变量有哪些?

类与类之间的关系是继承还是聚合?

这些看似简单的问题,却往往是困扰我们进行有效设计的关键因素,通常系统需求描述的核心名词,可以抽象为类,而对这些名词驱动的动作,可以对应地抽象为方法。当然,具体的设计思路要根据具体的需求情况,在整体架构目标的基础上进行有效的筛选、剥离和抽象。取舍之间,彰显OO智慧与设计模式的魅力。

那么,了解这些选项与原则,我们就不难理解关于字段、属性和方法的实现思路了,这些规则可以从对字段、属性和方法的探索中找到痕迹,然后从反方向来完善我们对于如何设计的思考与理解。

1.字段

字段(field)通常定义为private,表示类的状态信息。CLR支持只读和读写字段。值得注意的是,大部分情况下字段都是可读可写的,只读字段只能在构造函数中被赋值,其他方法不能改变只读字段。常见的字段定义为:

public class Client

{

    private string name;           //用户姓名

    private int age;            //用户年龄

    private string password;      //用户密码

}

如果以public表示类的状态信息,则我们就可以以类实例访问和改变这些字段内容,例如:

public static void Main ()

{

    Client xiaoWang = new Client();

    xiaoWang.name = "Xiao Wang";

    xiaoWang.age = 27;

    xiaoWang.password = "123456"

}

这样看起来并没有带来什么问题,Client实例通过操作公有字段很容易达到存取状态信息的目的,然而封装原则告诉我们:类的字段信息最好以私有方式提供给类的外部,而不是以公有方式来实现,否则不适当的操作将造成不必要的错误方式,破坏对象的状态信息,数据安全性和可靠性无法保证。例如:

xiaoWang.age = 1000;

xiaoWang.password = "5&@@Ld;afk99";

显然,小王的年龄不可能是1000岁,他是人不是怪物;小王的密码也不可能是“@&;”这些特殊符号,因为ATM机上根本没有这样的按键,而且密码必须是6位。所以对字段公有化的操作,会引起对数据安全性与可靠性的破坏,封装的第一个原则就是:将字段定义为private

那么,如上文所言,将字段设置为private后,对对象状态信息的控制又该如何实现呢?小王的状态信息必须以另外的方式提供给类外部访问或者改变。同时我们也期望除了实现对数据的访问,最好能加入一定的操作,达到数据控制的目的。因此,面向对象引入了另一个重量级的概念:属性。

2.属性

属性(property)通常定义为public,表示类的对外成员。属性具有可读、可写属性,通过getset访问器来实现其读写控制。例如上文中Client类的字段,我们可以相应地封装其为属性:

public class Client

{

    private string name;         //用户姓名

    public string Name

    {

        get { return name; }

        set

        {

            name = value == null ? String.Empty : value;

        }

    }

    private int age;                //用户年龄

    public int Age

    {

        get { return age; }

        set

        {

            if ((value > 0) && (value < 150))

            {

                age = value;

            }

            else

            {

                throw new ArgumentOutOfRangeException ("年龄信息不正确。");

            }

        }

    }

}

当我们再次以

xiaoWang.Age = 1000;

这样的方式来实现对小王的年龄进行写控制时,自然会弹出异常提示,从而达到了保护数据完整性的目的。

那么,属性的getset访问器怎么实现对对象属性的读写控制呢?我们打开ILDASM工具查看client类反编译后的情况时,会发现如图1-10所示的情形。

1-10  Client类的IL结构

由图1-10可见,IL中不存在getset方法,而是分别出现了get_Ageset_Age这样的方法,打开其中的任意方法分析会发现,编译器的执行逻辑是:如果发现一个属性,并且查看该属性中实现了get还是set,就对应地生成get_属性名、set_属性名两个方法。因此,我们可以说,属性的实质其实就是在编译时分别将getset访问器实现为对外方法,从而达到控制属性的目的,而对属性的读写行为伴随的实际是一个相应方法的调用,它以一种简单的形式实现了方法。

所以我们也可以定义自己的getset访问器,例如:

public string get_Password()

{

    return password;

}

public string set_Password(string value)

{

    if (value.Length < 6)

        password = value;

}

事实上,这种实现方法正是Java语言所采用的机制,而这样的方式显然没有实现getset访问器来得轻便,而且对属性的操作也带来多余的麻烦,所以我们推荐的还是下面的方式:

public string Password

{

    get { return password; }

    set

    {

        if (value.Length < 6)

            password = value;

    }

}

另外,getset对属性的读写控制,是通过实现getset的组合来实现的,如果属性为只读,则只实现get访问器即可;如果属性为可写,则实现set访问器即可。

通过对公共属性的访问来实现对类状态信息的读写控制,主要有两点好处:一是避免了对数据安全的访问限制,包含内部数据的可靠性;二是避免了类扩展或者修改带来的变量连锁反应。

至于修改变量带来的连锁反应,表现在对类的状态信息的需求信息发生变化时,如何来减少代码重构基础上,实现最小的损失和最大的补救。例如,如果对client的用户姓名由原来的简单name来标识,换成以firstNamesecondName来实现,如果不是属性封装了字段而带来的隐藏内部细节的特点,那么我们在代码中就要拼命地替换原来xiaoWang.name这样的实现了。例如:

private string firstName;

private string secondName;

public string Name

{

    get { return firstName + secondName; }

}

这样带来的好处是,我们只需要更改属性定义中的实现细节,而原来程序xiaoWang.name这样的实现就不需要做任何修改即可适应新的需求。你看,这就是封装的强大力量使然。

还有一种含参属性,在C#中称为索引器(indexer),对CLR来说并没有含不含参数的区别,它只是负责将相应的访问器实现为对应的方法,不同的是含参属性中加入了对参数的处理过程罢了。

3.方法

方法(method)封装了类的行为,提供了类的对外表现。用于将封装的内部细节以公有方法提供对外接口,从而实现与外部的交互与响应。例如,从上面属性的分析我们可知,实际上对属性的读写就是通过方法来实现的。因此,对外交互的方法,通常实现为public

当然不是所有的方法都被实现为public,否则类内部的实现岂不是全部暴露在外。必须对对外的行为与内部操作行为加以区分。因此,通常将在内部的操作全部以private方式来实现,而将需要与外部交互的方法实现为public,这样既保证了对内部数据的隐藏与保护,又实现了类的对外交互。例如在ATM类中,对钱的计算、用户验证这些方法涉及银行的关键数据与安全数据的保护问题,必须以private方法来实现,以隐藏对用户不透明的操作,而只提供返回钱款这一public方法接口即可。在封装原则中,有效地保护内部数据和有效地暴露外部行为一样关键。

那么这个过程应该如何来实施呢?还是回到ATM类的实例中,我们首先关注两个方法:IsValidUser()CashProcess(),其中IsValidUser()用于验证用户的合法性,而CashProcess()用于提供用户操作接口。显然,验证用户是银行本身的事情,外部用户无权访问,它主要用于在内部进行验证处理操作,例如CashProcess()中就以IsValidUser()作为方法的进入条件,因此很容易知道IsValidUser()被实现为private。而CashProcess()用于和外部客户进行交互操作,这正是我们反复强调的外部接口方法,显然应该实现为public。其他的方法GetUser()GetCash()也是从这一主线出发来确定其对外封装权限的,自然就能找到合理的定位。从这个过程中我们发现,谁为公有、谁为私有,取决于需求和设计双重因素,在职责单一原则下为类型设计方法,应该广泛考虑的是类本身的功能性,从开发者与设计者两个角度出发,分清访问权限就会水到渠成。

封装的意义

通过对字段、属性与方法在封装性这一点上的分析,我们可以更加明确地了解到封装特性作为面向对象的三大特性之一,表现出来的无与伦比的重要性与必要性,对于深入地理解系统设计与类设计提供了绝好的切入点。

下面,我们针对上文的分析进行小结,以便更好地理解我们对于封装所提出的思考,主要包括:

1字段通常定义为private,属性通常实现为public,而方法在内部实现为private,对外部实现为public,从而保证对内部数据的可靠性读写控制,保护了数据的安全和可靠,同时又提供了与外部接口的有效交互。这是类得以有效封装的基础机制。

2)通常情况下的理解正如我们上面提到的规则,但是具体的操作还要根据实际的设计需求而定,例如有些时候将属性实现为private,也将方法实现为private是更好的选择。例如在ATM类中,可能需要提供计数器来记录更新或者选择的次数,而该次数对用户而言是不必要的状态信息,因此只需在ATM类内部实现为private即可;同理,类型中的某些方法是对内部数据的操作,因此也以private方式来提供,从而达到数据安全的目的。

3)从内存和数据持久性角度上来看,有一个很重要但常常被忽视的事实是,封装属性提供了数据持久化的有效手段。因为,对象的属性和对象一样在内存期间是常驻的,只要对象不被垃圾回收,其属性值也将一直存在,并且记录最近一次对其更改的数据。

4)在面向对象中,封装的意义还远不止类设计层面对字段、属性和方法的控制,更重要的是其广义层面。我们理解的封装,应该是以实现UI分离为目的的软件设计方法,一个系统或者软件开发之后,从维护和升级的目的考虑,一定要保证对外接口部分的绝对稳定。不管系统内部的功能性实现如何多变,保证接口稳定是保证软件兼容、稳定、健壮的根本。所以OO智慧中的封装性旨在保证:

隐藏系统实现的细节,保证系统的安全性和可靠性。

提供稳定不变的对外接口。因此,系统中相对稳定部分常被抽象为接口。

封装保证了代码模块化,提高了软件的复用和功能分离。

封装规则

现在,我们对封装特性的规则做一个总结,这些规则就是在平常的实践中提炼与完善出的良药,我们在进行实际的开发和设计工作时,应尽量遵守规则,而不是盲目地寻求方法。

尽可能地调用类的访问器,而不是成员,即使在类的内部。其目的在我们的示例中已有说明,例如Client类中的Name属性就可以避免由于需求变化带来的代码更改问题。

内部私有部分可以任意更改,但是一定要在保证外部接口稳定的前提下。

将对字段的读写控制实现为属性,而不是方法,否则舍近而求远,非明智之选。

类封装是由访问权限来保证的,对内实现为private,对外实现为public。再结合继承特性,还要对protectedinternal有较深的理解,详细的情况参见1.1对象的旅行

封装的精华是封装变化。张逸在《软件设计精要与模式》一书中指出,封装变化是面向对象思想的核心,他提到开发者应从设计角度和使用角度两方面来分析封装。因此,我们将系统中变化频繁的部分封装为独立的部分,这种隔离选择有利于充分的软件复用和系统柔性。

 结论

封装是什么?横扫全文,我们的结论是:封装就是一个包装,将包装的内外分为两个空间,对内实现数据私有,对外实现方法调用,保证了数据的完整性和安全性。

我们从封装的意义谈起,然后逐层深入到对字段、属性和方法在定义和实现上的规则,这是一次自上而下的探求方式,也是一次反其道而行的揭密旅程。关于封装,远不是本节所能全面展现的话题,关于封装的技巧和更多深入的探求,来自于面向对象,来自于设计模式,也来自于软件工程。因此,要想全面而准确地认识封装,除了本节打下的基础之外,不断的在实际学习中完善和总结是不可缺少的,这在 .NET 学习中也是至关重要的。

你可能感兴趣的:(封装)