65.9K
CodeProject 正在变化。 阅读更多。
Home

为什么封装经常被忽视?

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.62/5 (23投票s)

2014年10月20日

CPOL

6分钟阅读

viewsIcon

37514

我参与过的项目中的常见问题。

引言

我开始在一个新项目(对我来说是新的,实际上项目已经开发了一段时间)工作,再次遇到了我非常常见的 OOP 设计问题。任何基础的 OOP 课程都会介绍核心原则:封装、抽象、继承、多态。尽管它们很重要,但我发现更多类没有遵循它们,而不是遵守了它们。出于某些原因,这些概念没有被很好地内化——感觉就像每个人都熟记歌词,但很少有人能真正理解它们所讲述的故事。让我们从第一个开始,封装。

封装

在我作为开发者的职业生涯中,我大部分时间都使用 C#,并且我认为它是一个伟大的语言——这显然是一个主观的看法。我喜欢它,但也越来越不喜欢它的一些地方,那就是属性。不是因为它们没有用,而是因为它们让做不该做的事情(打破封装)变得非常容易。我不会详细介绍如何以及为什么会这样,因为这已经在其他地方解释过了,比如 http://typicalprogrammer.com/doing-it-wrong-getters-and-setters/http://c2.com/cgi/wiki?AccessorsAreEvil。有人可能会争辩说,属性和其他所有东西一样,可以正确使用,也可以错误使用。是的,但定义访问器的简洁语法以及暴露类内部信息的便捷方式,在某种程度上让开发者认为这样做也未尝不可。平台(包括编程语言)应该让不那么可取的操作难以执行,让更可取的操作易于执行。

我们来看一个例子。我参与过的所有项目都有一些针对用户的设置(用户选择的语言偏好、时区、主题等)或一些全局设置(要使用的服务器 URL 等),这些设置保存在后端数据库中有意义。因此,每个项目都有一个类,比如 Settings ,用于检索和保存这些设置。在我见过的绝大多数情况下,该类如下所示:

public class Configuration
{
    public string Key { get; set; }
    public string Value { get; set; }
}
public class Settings
{
    public Configuration Get(string key)
    {
       Configuration record = null;
       //load the record from db
       return record;
    }
...
}

//the usage looks like this
var settings = new Settings();
var config = settings.Get("Timeout");
int timeout;
if(!int.TryParse(config.Value, out timeout))
{
    throw new InvalidOperationException("Timeout key not found");
}
//code which uses timeout variable
...

为什么这段代码远远不够好?原因有很多,但让我们从封装开始。Settings 类暴露了它内部的信息:数据是以键/值对的形式存储的,其中键和值都是字符串(Configuration 类),并且有一个键叫“Timeout”。

打破封装实际上是坏的,因为它使发生这种情况的系统难以维护。为什么难以维护?开始咆哮:驱动和塑造软件开发(以及作为重要组成部分之一的编程语言)的主要力量是管理复杂性。这就是为什么我们不使用机器码、汇编语言,或者 C(对大多数人来说)。那么我们如何管理复杂性呢?通过将复杂问题分解成更小、更简单的问题,构建内部解决这些问题的抽象,然后组合这些抽象来解决初始问题。在 OOP 中,类用于实现这两种管理复杂性的方法。类应该有一个可以(在一定程度上)与系统其他部分隔离的责任。当需要更改该功能时,如果该类实现良好,那么开发人员只需要理解该类就可以进行更改,而无需理解整个系统(即使是依赖于它的类,如果契约没有改变)。在争论为什么应该是这样时,一个常见的论点是:如果底层实现必须改变,比如从使用数据库存储 x 变为数据库存储 y,那么这样做会很容易。这个论点削弱了真正的原因,因为每个有经验的开发人员都知道这种情况不太可能发生。用一种实现替换另一种实现并不是真正核心的原因,但如果能做到这一点,就证明了封装得到了尊重。如前所述,原因是隔离该类中定义的行为与系统的其余部分,以便更容易理解和更改该行为。咆哮结束,让我们回到我们的类。

为什么这种经常发现的实现更难维护?假设我们要添加一种新的超时类型——第一个指定的是数据库连接的超时,现在我们需要一个 Web 服务器连接的超时。我们想将第一个重命名为“WebTimeout”,并将第二个命名为“DbTimeout”。在这种实现中,我们将不得不搜索“timeout”的所有用法(这个字符串可能在许多其他地方用于不同目的),然后进行更改。考虑到这一点,我们改变了 Settings 类:

public class Configuration
{
    public string Key { get; set; }
    public string Value { get; set; }
}
public class Settings
{
    public const string DbTimeout = "db_timeout";
    public const string WebTimeout = "web_timeout";
    public Configuration Get(string key)
    {
        Configuration record = null;
        //load the record from db
        return record;
    }
...
}
//usage
var settings = new Settings();
var config = settings.Get(Settings.DbTimeout);
int timeout;
if(!int.TryParse(config.Value, out timeout))
{
    throw new ArgumentOutOfRangeException(Settings.DbTimeout + " not found");
}
//code which uses timeout variable
...

假设请求了另一个更改:如果未指定值,我们想使用默认值。要实现这一点,我们必须搜索每个键的使用,并按如下方式更新代码:

...
var settings = new Settings();
var config = settings.Get(Settings.DbTimeout);
int timeout;
if(!int.TryParse(config.Value, out timeout))
{
    throw new ArgumentOutOfRangeException(Settings.DbTimeout + " not found");
}
else
{
    timeout = 1000;
}
//code which uses timeout variable
...

类似地,如果默认值发生变化,那么我们就必须搜索所有使用 Settings.DbTimeout 键的地方并进行更改。存在遗漏一些地方的风险,因此为了避免这种情况,我们可以在 Settings 类中添加一个常量,例如:

public const int DefaultDbTimeout = 1000;

这会好一些,但我们可以继续下去,随着其他更改的请求,我们将不得不去修改所有使用键的地方。糟糕的设计让使用设置的类承担了对其内部知识的负担,并且对 Settings 类的任何更改都会波及所有这些类。那么我们如何尊重封装呢?问以下问题很有帮助:为了获得我需要的东西,我必须传递最少的信息是什么?哪些响应最能满足我的需求?一个典型的答案看起来像这样:

public class Settings
{
    private const int DefaultDbTimeout = 1000;
    public const string DbTimeout = "db_timeout";
    public const string WebTimeout = "web_timeout";
    public int GetInt(string key)
    {
       string value = null;//GetFromDb(key).Value;//load the record from db
       int result;
       if (!int.TryParse(value, out result))
       {
           switch (key)
           {
               case DbTimeout:
                   result = DefaultDbTimeout;
                   break;
               //....some other cases
               default:
                   throw new ArgumentOutOfRangeException(key + " not found");
           }
        }
        return result;
    }
...
}

//usage
var settings = new Settings();
var timeout = settings.GetInt(Settings.DbTimeout);
//code which uses timeout variable
...

这有所改进——我们不再返回 Configuration 实例,因此该类可以更改而不会影响 Settings 的客户端(使用 Settings 实例的类),因为这 anyway 没有带来任何好处。我们消除了客户端中所有键的重复代码,将所有与键值相关的逻辑集中在一个地方——Settings 类。我们是否将 Settings 类的所有内部信息都隐藏了?嗯,并非如此——它们仍然知道它正在使用某种字符串键/值存储。这也可以更改——假设我们要优化查询,并且我们希望使用记录的 id 而不是作为参数提供的字符串键——我们仍然在暴露不应该暴露的内部逻辑。我们可以消除这些信息:

public class Settings
{
    private const int DefaultDbTimeout = 1000;
    private const string DbTimeout = "db_timeout";
    private const string WebTimeout = "web_timeout";
    public int GetDbTimeout()
    {
        string value = GetFromDb(DbTimeout);
        int result;
        if (!int.TryParse(value, out result))
        {
            result = DefaultDbTimeout;
        }
        return result;
    }
    public int GetWebTimeout()
    {
        //actual implementation
        return 0;
    }
    private string GetFromDb(string key)
    {
        Configuration record = null;// GetFromDb(key);//load the record from db
        return record.Value;
    }
...
}
//usage
var settings = new Settings();
var timeout = settings.GetDbTimeout();
//code which uses timeout variable
...

Settings 类的内部实现对于本次讨论来说不太重要——它可以得到改进。重要的是,我们现在公开了一个方法,该方法将返回具有所需类型的相应键值。

结论

这个例子非常简单且常见,但尽管如此,我发现它更常像第一种方法一样实现,而几乎从不像最后一种方法那样实现,即使是由资深开发者编写的。我本应在我的项目中遇到更微妙的问题,但似乎即使是基本的 OOP 设计原则也内化和应用不佳。我对您在这件事上的经历很感兴趣,所以请分享。您为什么认为会发生这种情况?

© . All rights reserved.