Skip to content

第 16 章 Refactoring SerialDate 重构 SerialDate

If you go to http://www.jfree.org/jcommon/index.php, you will find the JCommon library. Deep within that library there is a package named org.jfree.date. Within that package there is a class named SerialDate. We are going to explore that class.

如果你访问 http://www.jfree.org/jcommon/index.php,就能找到 JCommon 库。在这个库的深处有一个名为 org.jfree.date 的包,包中有一个名为 SerialDate 的类。我们将对这个类进行深入探索。

The author of SerialDate is David Gilbert. David is clearly an experienced and competent programmer. As we shall see, he shows a significant degree of professionalism and discipline within his code. For all intents and purposes, this is "good code." And I am going to rip it to pieces.

SerialDate 的作者是 David Gilbert。David 显然是一位经验丰富且能力出众的程序员。正如我们即将看到的,他的代码展现出了相当程度的专业素养和严谨纪律。从各方面来看,这都是"好代码"。而我将要把它撕个粉碎。

This is not an activity of malice. Nor do I think that I am so much better than David that I somehow have a right to pass judgment on his code. Indeed, if you were to find some of my code, I'm sure you could find plenty of things to complain about.

这并非出于恶意。我也不认为自己比 David 高明多少,以至于有资格对他的代码评头论足。事实上,如果你去翻翻我的代码,我相信你能找到一大堆可以吐槽的地方。

No, this is not an activity of nastiness or arrogance. What I am about to do is nothing more and nothing less than a professional review. It is something that we should all be comfortable doing. And it is something we should welcome when it is done for us. It is only through critiques like these that we will learn. Doctors do it. Pilots do it. Lawyers do it. And we programmers need to learn how to do it too.

不,这不是恶意攻击或狂妄自大。我接下来要做的,不多不少,正是一次专业的代码评审。这是我们都应该习以为常的事情,也是当别人为我们做这件事时应该欢迎的事情。只有通过这样的批评,我们才能进步。医生这样做,飞行员这样做,律师这样做,我们程序员也需要学会这样做。

One more thing about David Gilbert: David is more than just a good programmer. David had the courage and good will to offer his code to the community at large for free. He placed it out in the open for all to see and invited public usage and public scrutiny. This was well done!

还有关于 David Gilbert 的一件事:David 不仅仅是一位优秀的程序员。他有勇气和善意将自己的代码免费提供给社区。他将代码公之于众,邀请所有人使用和审视。这件事做得漂亮!

SerialDate (Listing B-1, page 349) is a class that represents a date in Java. Why have a class that represents a date, when Java already has java.util.Date and java.util.Calendar, and others? The author wrote this class in response to a pain that I have often felt myself. The comment in his opening Javadoc (line 67) explains it well. We could quibble about his intention, but I have certainly had to deal with this issue, and I welcome a class that is about dates instead of times.

SerialDate(代码清单 B-1,第 349 页)是一个用于在 Java 中表示日期的类。既然 Java 已经有了 java.util.Date、java.util.Calendar 等等,为什么还需要一个表示日期的类呢?作者编写这个类是为了回应一种我经常感受到的痛苦。他开篇 Javadoc 中的注释(第 67 行)对此解释得很好。我们可以对他的意图吹毛求疵,但我自己确实不得不面对这个问题,所以我欢迎一个只关乎日期而非时间的类。

16.1 FIRST, MAKE IT WORK 先让它能用

There are some unit tests in a class named SerialDateTests (Listing B-2, page 366). The tests all pass. Unfortunately a quick inspection of the tests shows that they don't test everything [T1]. For example, doing a "Find Usages" search on the method MonthCodeToQuarter (line 334) indicates that it is not used [F4]. Therefore, the unit tests don't test it.

在一个名为 SerialDateTests 的类(代码清单 B-2,第 366 页)中有一些单元测试。测试全部通过。但不幸的是,快速检查一下就能发现这些测试并没有覆盖所有内容 [T1]。例如,对 MonthCodeToQuarter 方法(第 334 行)执行"查找用法"搜索,会发现它根本没有被使用过 [F4]。因此,单元测试自然也没有测试它。

So I fired up Clover to see what the unit tests covered and what they didn't. Clover reported that the unit tests executed only 91 of the 185 executable statements in SerialDate (~50 percent) [T2]. The coverage map looks like a patchwork quilt, with big gobs of unexecuted code littered all through the class.

于是我启动了 Clover,看看单元测试到底覆盖了哪些、遗漏了哪些。Clover 报告称,单元测试只执行了 SerialDate 中 185 条可执行语句中的 91 条(约 50%)[T2]。覆盖率图看起来就像一块拼布被单,大块未执行的代码散落在整个类中。

It was my goal to completely understand and also refactor this class. I couldn't do that without much greater test coverage. So I wrote my own suite of completely independent unit tests (Listing B-4, page 374).

我的目标是完全理解并重构这个类。没有更高的测试覆盖率,我无法做到这一点。于是我编写了自己的一套完全独立的单元测试(代码清单 B-4,第 374 页)。

As you look through these tests, you will note that many of them are commented out. These tests didn't pass. They represent behavior that I think SerialDate should have. So as I refactor SerialDate, I'll be working to make these tests pass too.

浏览这些测试时,你会注意到其中很多被注释掉了。这些测试没有通过。它们代表了我认为 SerialDate 应该具备的行为。所以在重构 SerialDate 的过程中,我也会努力让这些测试通过。

Even with some of the tests commented out, Clover reports that the new unit tests are executing 170 (92 percent) out of the 185 executable statements. This is pretty good, and I think we'll be able to get this number higher.

即使有一些测试被注释掉了,Clover 报告新的单元测试执行了 185 条可执行语句中的 170 条(92%)。这相当不错,我认为我们还能把这个数字提得更高。

The first few commented-out tests (lines 23-63) were a bit of conceit on my part. The program was not designed to pass these tests, but the behavior seemed obvious [G2] to me. I'm not sure why the testWeekdayCodeToString method was written in the first place, but because it is there, it seems obvious that it should not be case sensitive. Writing these tests was trivial [T3]. Making them pass was even easier; I just changed lines 259 and 263 to use equalsIgnoreCase.

前几个被注释掉的测试(第 23-63 行)是我有点自作主张了。程序的设计初衷并非通过这些测试,但这些行为在我看来是显而易见的 [G2]。我不确定 testWeekdayCodeToString 方法当初为什么要那样写,但既然它在那儿,显然它不应该区分大小写。编写这些测试毫不费力 [T3],让它们通过更是轻而易举——我只需将第 259 行和第 263 行改为使用 equalsIgnoreCase 即可。

I left the tests at line 32 and line 45 commented out because it's not clear to me that the "tues" and "thurs" abbreviations ought to be supported.

我保留了第 32 行和第 45 行测试的注释状态,因为我不确定是否应该支持"tues"和"thurs"这样的缩写。

The tests on line 153 and line 154 don't pass. Clearly, they should [G2]. We can easily fix this, and the tests on line 163 through line 213, by making the following changes to the stringToMonthCode function.

第 153 行和第 154 行的测试没有通过。它们显然应该通过 [G2]。我们可以轻松地修复这个问题——以及第 163 行到第 213 行的测试——只需对 stringToMonthCode 函数做如下修改。

java
   457     if ((result < 1) || (result > 12)) {
               result = -1;
   458         for (int i = 0; i < monthNames.length; i++) {
   459             if (s.equalsIgnoreCase(shortMonthNames[i])) {
   460                 result = i + 1;
   461                 break;
   462             }
   463             if (s.equalsIgnoreCase(monthNames[i])) {
   464                 result = i + 1;
   465                 break;
   466             }
   467         }
   468     }

The commented test on line 318 exposes a bug in the getFollowingDayOfWeek method (line 672). December 25th, 2004, was a Saturday. The following Saturday was January 1st, 2005. However, when we run the test, we see that getFollowingDayOfWeek returns December 25th as the Saturday that follows December 25th. Clearly, this is wrong [G3],[T1]. We see the problem in line 685. It is a typical boundary condition error [T5]. It should read as follows:

第 318 行被注释的测试暴露了 getFollowingDayOfWeek 方法(第 672 行)中的一个 bug。2004 年 12 月 25 日是星期六。接下来的星期六是 2005 年 1 月 1 日。然而,当我们运行测试时,getFollowingDayOfWeek 却返回 12 月 25 日作为 12 月 25 日之后的那个星期六。显然,这是错误的 [G3],[T1]。问题出在第 685 行,是一个典型的边界条件错误 [T5]。它应该写成:

java
   685     if (baseDOW >= targetWeekday) {

It is interesting to note that this function was the target of an earlier repair. The change history (line 43) shows that "bugs" were fixed in getPreviousDayOfWeek, getFollowingDayOfWeek, and getNearestDayOfWeek [T6].

有趣的是,这个函数曾经被修复过。变更历史(第 43 行)显示 getPreviousDayOfWeek、getFollowingDayOfWeek 和 getNearestDayOfWeek 中的"bug"曾被修复过 [T6]。

The testGetNearestDayOfWeek unit test (line 329), which tests the getNearestDayOfWeek method (line 705), did not start out as long and exhaustive as it currently is. I added a lot of test cases to it because my initial test cases did not all pass [T6]. You can see the pattern of failure by looking at which test cases are commented out. That pattern is revealing [T7]. It shows that the algorithm fails if the nearest day is in the future. Clearly there is some kind of boundary condition error [T5].

testGetNearestDayOfWeek 单元测试(第 329 行)用于测试 getNearestDayOfWeek 方法(第 705 行),它一开始并没有现在这么长和全面。我添加了很多测试用例,因为我最初的测试用例并非全部通过 [T6]。看看哪些测试用例被注释掉了,你就能看出失败的模式。这个模式很有启发性 [T7]。它表明,当最近的那一天在未来时,算法就会失败。显然存在某种边界条件错误 [T5]。

The pattern of test coverage reported by Clover is also interesting [T8]. Line 719 never gets executed! This means that the if statement in line 718 is always false. Sure enough, a look at the code shows that this must be true. The adjust variable is always negative and so cannot be greater or equal to 4. So this algorithm is just wrong.

Clover 报告的测试覆盖模式也很有意思 [T8]。第 719 行永远不会被执行!这意味着第 718 行的 if 语句永远为假。果然,查看代码后证实确实如此。adjust 变量始终为负数,因此不可能大于或等于 4。所以这个算法就是错的。

The right algorithm is shown below:

正确的算法如下所示:

java
     int delta = targetDOW - base.getDayOfWeek();
     int positiveDelta = delta + 7;
     int adjust = positiveDelta % 7;
     if (adjust > 3)
       adjust -= 7;
 
     return SerialDate.addDays(adjust, base);

Finally, the tests at line 417 and line 429 can be made to pass simply by throwing an IllegalArgumentException instead of returning an error string from weekInMonthToString and relativeToString.

最后,第 417 行和第 429 行的测试只需抛出 IllegalArgumentException,而不是从 weekInMonthToString 和 relativeToString 返回错误字符串,就能通过。

With these changes all the unit tests pass, and I believe SerialDate now works. So now it's time to make it "right."

经过这些修改,所有单元测试都通过了,我相信 SerialDate 现在能正常工作了。那么现在是时候让它变得"正确"了。

16.2 THEN MAKE IT RIGHT 然后让它正确

We are going to walk from the top to the bottom of SerialDate, improving it as we go along. Although you won't see this in the discussion, I will be running all of the JCommon unit tests, including my improved unit test for SerialDate, after every change I make. So rest assured that every change you see here works for all of JCommon.

我们将从 SerialDate 的顶部一路走到底部,边走边改进。虽然你在讨论中看不到,但我在每次修改之后都会运行所有 JCommon 单元测试,包括我为 SerialDate 编写的改进版单元测试。所以请放心,你在这里看到的每一处修改都适用于整个 JCommon。

Starting at line 1, we see a ream of comments with license information, copyrights, authors, and change history. I acknowledge that there are certain legalities that need to be addressed, and so the copyrights and licenses must stay. On the other hand, the change history is a leftover from the 1960s. We have source code control tools that do this for us now. This history should be deleted [C1].

从第 1 行开始,我们看到一大堆注释,包含许可证信息、版权声明、作者列表和变更历史。我承认某些法律事项需要被妥善处理,因此版权和许可证信息必须保留。但另一方面,变更历史是上世纪 60 年代的遗留物。我们现在有了源代码管理工具来做这件事。这些历史记录应该被删除 [C1]。

The import list starting at line 61 could be shortened by using java.text.* and java.util.*. [J1]

从第 61 行开始的导入列表可以通过使用 java.text.* 和 java.util.* 来缩短。[J1]

I wince at the HTML formatting in the Javadoc (line 67). Having a source file with more than one language in it troubles me. This comment has four languages in it: Java, English, Javadoc, and html [G1]. With that many languages in use, it's hard to keep things straight. For example, the nice positioning of line 71 and line 72 are lost when the Javadoc is generated, and yet who wants to see <ul> and <li> in the source code? A better strategy might be to just surround the whole comment with <pre> so that the formatting that is apparent in the source code is preserved within the Javadoc.1

我对 Javadoc 中的 HTML 格式感到不安(第 67 行)。源文件中包含多种语言让我很困扰。这条注释里有四种语言:Java、英语、Javadoc 和 HTML [G1]。同时使用这么多种语言,很难保持条理清晰。例如,第 71 行和第 72 行精心排版的位置信息在生成 Javadoc 时会丢失,可是谁又想在源代码里看到 <ul><li> 呢?更好的策略或许是将整段注释用 <pre> 包裹起来,这样源代码中明显的格式就能在 Javadoc 中保留下来。[^1]

[^1]: An even better solution would have been for Javadoc to present all comments as preformatted, so that comments appear the same in both code and document. [^1]: 更好的解决方案是让 Javadoc 将所有注释以预格式化方式呈现,这样注释在代码和文档中的显示就完全一致了。

Line 86 is the class declaration. Why is this class named SerialDate? What is the significance of the word "serial"? Is it because the class is derived from Serializable? That doesn't seem likely.

第 86 行是类声明。为什么这个类叫 SerialDate?"serial"这个词有什么特殊含义吗?是因为它继承自 Serializable 吗?看起来不太像。

I won't keep you guessing. I know why (or at least I think I know why) the word "serial" was used. The clue is in the constants SERIAL_LOWER_BOUND and SERIAL_UPPER_BOUND on line 98 and line 101. An even better clue is in the comment that begins on line 830. This class is named SerialDate because it is implemented using a "serial number," which happens to be the number of days since December 30th, 1899.

我就不卖关子了。我知道(或者至少我认为我知道)为什么使用"serial"这个词。线索在第 98 行和第 101 行的常量 SERIAL_LOWER_BOUND 和 SERIAL_UPPER_BOUND 中。更好的线索在第 830 行开始的注释中。这个类之所以叫 SerialDate,是因为它使用"序列号"来实现,而这个序列号恰好是自 1899 年 12 月 30 日以来的天数。

I have two problems with this. First, the term "serial number" is not really correct. This may be a quibble, but the representation is more of a relative offset than a serial number. The term "serial number" has more to do with product identification markers than dates. So I don't find this name particularly descriptive [N1]. A more descriptive term might be "ordinal."

我对此有两个问题。首先,"序列号"这个术语并不准确。这或许有些吹毛求疵,但这种表示方法更像是一个相对偏移量而非序列号。"序列号"这个词更多地与产品标识有关,而非日期。所以我觉得这个名字描述性不够强 [N1]。一个更具描述性的术语可能是"序数"(ordinal)。

The second problem is more significant. The name SerialDate implies an implementation. This class is an abstract class. There is no need to imply anything at all about the implementation. Indeed, there is good reason to hide the implementation! So I find this name to be at the wrong level of abstraction [N2]. In my opinion, the name of this class should simply be Date.

第二个问题更为重要。SerialDate 这个名字暗示了实现方式。这个类是一个抽象类,完全没有必要暗示任何实现细节。实际上,我们有充分的理由隐藏实现!所以我觉得这个名字的抽象层次不对 [N2]。在我看来,这个类的名字应该就叫 Date。

Unfortunately, there are already too many classes in the Java library named Date, so this is probably not the best name to choose. Because this class is all about days, instead of time, I considered naming it Day, but this name is also heavily used in other places. In the end, I chose DayDate as the best compromise.

不幸的是,Java 库中已经有很多叫 Date 的类了,所以这可能不是最好的选择。因为这个类只关乎日期而非时间,我考虑过叫它 Day,但这个名字在其他地方也被大量使用。最终,我选择了 DayDate 作为最佳折中方案。

From now on in this discussion I will use the term DayDate. I leave it to you to remember that the listings you are looking at still use SerialDate.

从现在起,在本文的讨论中我将使用 DayDate 这个名称。请记住,你所看到的代码清单中仍然使用 SerialDate。

I understand why DayDate inherits from Comparable and Serializable. But why does it inherit from MonthConstants? The class MonthConstants (Listing B-3, page 372) is just a bunch of static final constants that define the months. Inheriting from classes with constants is an old trick that Java programmers used so that they could avoid using expressions like MonthConstants.January, but it's a bad idea [J2]. MonthConstants should really be an enum.

我理解 DayDate 为什么继承自 Comparable 和 Serializable,但它为什么要继承自 MonthConstants 呢?MonthConstants 类(代码清单 B-3,第 372 页)只不过是一堆定义月份的静态常量。继承包含常量的类是 Java 程序员过去常用的老把戏,这样可以避免写 MonthConstants.January 这样的表达式,但这是个坏主意 [J2]。MonthConstants 真的应该是一个枚举。

java
   public abstract class DayDate implements Comparable,
                                            Serializable {
     public static enum Month {
       JANUARY(1),
       FEBRUARY(2),
       MARCH(3),
       APRIL(4),
       MAY(5),
       JUNE(6),
       JULY(7),
       AUGUST(8),
       SEPTEMBER(9),
       OCTOBER(10),
       NOVEMBER(11),
       DECEMBER(12);
 
       Month(int index) {
         this.index = index;
       }

       public static Month make(int monthIndex) {
         for (Month m : Month.values()) {
           if (m.index == monthIndex)
             return m;
         }
         throw new IllegalArgumentException("Invalid month index " + monthIndex);
       }
       public final int index;
     }

Changing MonthConstants to this enum forces quite a few changes to the DayDate class and all it's users. It took me an hour to make all the changes. However, any function that used to take an int for a month, now takes a Month enumerator. This means we can get rid of the isValidMonthCode method (line 326), and all the month code error checking such as that in monthCodeToQuarter (line 356) [G5].

将 MonthConstants 改为这个枚举,迫使 DayDate 类及其所有使用者做出大量修改。我花了整整一个小时才完成所有修改。然而,任何过去用 int 表示月份的函数,现在都改用 Month 枚举器。这意味着我们可以去掉 isValidMonthCode 方法(第 326 行),以及所有月份代码的错误检查,比如 monthCodeToQuarter 中的那些(第 356 行)[G5]。

Next, we have line 91, serialVersionUID. This variable is used to control the serializer. If we change it, then any DayDate written with an older version of the software won't be readable anymore and will result in an InvalidClassException. If you don't declare the serialVersionUID variable, then the compiler automatically generates one for you, and it will be different every time you make a change to the module. I know that all the documents recommend manual control of this variable, but it seems to me that automatic control of serialization is a lot safer [G4]. After all, I'd much rather debug an InvalidClassException than the odd behavior that would ensue if I forgot to change the serialVersionUID. So I'm going to delete the variable—at least for the time being.2

接下来看第 91 行的 serialVersionUID。这个变量用于控制序列化器。如果我们修改它,那么用旧版软件写入的任何 DayDate 对象都将无法读取,并导致 InvalidClassException。如果你不声明 serialVersionUID 变量,编译器会自动为你生成一个,而且每次你修改模块时它都会不同。我知道所有文档都推荐手动控制这个变量,但在我看来,让序列化自动控制要安全得多 [G4]。毕竟,我宁愿调试一个 InvalidClassException,也不愿面对忘记修改 serialVersionUID 而引发的诡异行为。所以我打算删除这个变量——至少暂时如此。[^2]

[^2]: Several of the reviewers of this text have taken exception to this decision. They contend that in an open source framework it is better to assert manual control over the serial ID so that minor changes to the software don't cause old serialized dates to be invalid. This is a fair point. However, at least the failure, inconvenient though it might be, has a clear-cut cause. On the other hand, if the author of the class forgets to update the ID, then the failure mode is undefined and might very well be silent. I think the real moral of this story is that you should not expect to deserialize across versions. [^2]: 本文的几位审稿人对这一决定提出了异议。他们认为在开源框架中,最好手动控制序列化 ID,这样对软件的细微修改就不会导致旧的序列化日期失效。这是一个合理的观点。然而,至少这种失败——虽然可能不便——有一个明确的原因。另一方面,如果类的作者忘记更新 ID,那么失败模式是未定义的,而且很可能是静默的。我认为这个故事真正的教训是,你不应该指望跨版本反序列化。

I find the comment on line 93 redundant. Redundant comments are just places to collect lies and misinformation [C2]. So I'm going to get rid of it and its ilk.

我觉得第 93 行的注释是多余的。多余的注释只不过是谎言和错误信息的温床 [C2]。所以我打算把它以及类似的东西都删掉。

The comments at line 97 and line 100 talk about serial numbers, which I discussed earlier [C1]. The variables they describe are the earliest and latest possible dates that DayDate can describe. This can be made a bit clearer [N1].

第 97 行和第 100 行的注释谈到了序列号,我在前面已经讨论过了 [C1]。它们所描述的变量是 DayDate 能够描述的最早和最晚日期。这一点可以表述得更清楚一些 [N1]。

java
   public static final int EARLIEST_DATE_ORDINAL = 2;     // 1/1/1900
   public static final int LATEST_DATE_ORDINAL = 2958465; // 12/31/9999

It's not clear to me why EARLIEST_DATE_ORDINAL is 2 instead of 0. There is a hint in the comment on line 829 that suggests that this has something to do with the way dates are represented in Microsoft Excel. There is a much deeper insight provided in a derivative of DayDate called SpreadsheetDate (Listing B-5, page 382). The comment on line 71 describes the issue nicely.

我不明白为什么 EARLIEST_DATE_ORDINAL 是 2 而不是 0。第 829 行的注释暗示这与 Microsoft Excel 中日期的表示方式有关。在一个名为 SpreadsheetDate 的 DayDate 派生类(代码清单 B-5,第 382 页)中提供了更深入的解释。第 71 行的注释很好地描述了这个问题。

The problem I have with this is that the issue seems to be related to the implementation of SpreadsheetDate and has nothing to do with DayDate. I conclude from this that EARLIEST_DATE_ORDINAL and LATEST_DATE_ORDINAL do not really belong in DayDate and should be moved to SpreadsheetDate [G6].

我对此的看法是,这个问题似乎与 SpreadsheetDate 的实现有关,而与 DayDate 无关。由此我得出结论:EARLIEST_DATE_ORDINAL 和 LATEST_DATE_ORDINAL 并不属于 DayDate,应该移到 SpreadsheetDate 中 [G6]。

Indeed, a search of the code shows that these variables are used only within SpreadsheetDate. Nothing in DayDate, nor in any other class in the JCommon framework, uses them. Therefore, I'll move them down into SpreadsheetDate.

实际上,搜索代码后发现这些变量仅在 SpreadsheetDate 中使用。DayDate 以及 JCommon 框架中的其他任何类都没有使用它们。因此,我将把它们移入 SpreadsheetDate。

The next variables, MINIMUM_YEAR_SUPPORTED, and MAXIMUM_YEAR_SUPPORTED (line 104 and line 107), provide something of a dilemma. It seems clear that if DayDate is an abstract class that provides no foreshadowing of implementation, then it should not inform us about a minimum or maximum year. Again, I am tempted to move these variables down into SpreadsheetDate [G6]. However, a quick search of the users of these variables shows that one other class uses them: RelativeDayOfWeekRule (Listing B-6, page 390). We see that usage at line 177 and line 178 in the getDate function, where they are used to check that the argument to getDate is a valid year. The dilemma is that a user of an abstract class needs information about its implementation.

接下来的变量 MINIMUM_YEAR_SUPPORTED 和 MAXIMUM_YEAR_SUPPORTED(第 104 行和第 107 行)带来了一个两难困境。如果 DayDate 是一个不暗示任何实现细节的抽象类,那么它显然不应该告诉我们最小或最大年份。我再次想把这些变量移入 SpreadsheetDate [G6]。然而,快速搜索这些变量的使用者后发现,还有一个类在使用它们:RelativeDayOfWeekRule(代码清单 B-6,第 390 行)。我们在 getDate 函数的第 177 行和第 178 行看到了这个用法,它们用于检查 getDate 的参数是否是有效年份。困境在于,抽象类的使用者需要了解其实现的信息。

What we need to do is provide this information without polluting DayDate itself. Usually, we would get implementation information from an instance of a derivative. However, the getDate function is not passed an instance of a DayDate. It does, however, return such an instance, which means that somewhere it must be creating it. Line 187 through line 205 provide the hint. The DayDate instance is being created by one of the three functions, getPreviousDayOfWeek, getNearestDayOfWeek, or getFollowingDayOfWeek. Looking back at the DayDate listing, we see that these functions (lines 638-724) all return a date created by addDays (line 571), which calls createInstance (line 808), which creates a SpreadsheetDate! [G7].

我们需要做的是在不污染 DayDate 本身的前提下提供这些信息。通常,我们会从派生类的实例获取实现信息。然而,getDate 函数并没有接收一个 DayDate 实例。但它确实返回了这样一个实例,这意味着在某处它一定在创建这个实例。第 187 行到第 205 行给出了线索。DayDate 实例是由 getPreviousDayOfWeek、getNearestDayOfWeek 或 getFollowingDayOfWeek 这三个函数之一创建的。回顾 DayDate 的代码清单,我们看到这些函数(第 638-724 行)都返回由 addDays(第 571 行)创建的日期,而 addDays 调用了 createInstance(第 808 行),它创建的是一个 SpreadsheetDate![G7]

It's generally a bad idea for base classes to know about their derivatives. To fix this, we should use the ABSTRACT FACTORY3 pattern and create a DayDateFactory. This factory will create the instances of DayDate that we need and can also answer questions about the implementation, such as the maximum and minimum dates.

基类了解其派生类通常是个坏主意。为了解决这个问题,我们应该使用抽象工厂(ABSTRACT FACTORY)^3 模式,创建一个 DayDateFactory。这个工厂将创建我们需要的 DayDate 实例,同时也能回答关于实现的问题,比如最大和最小日期。

java
   public abstract class DayDateFactory {
     private static DayDateFactory factory = new SpreadsheetDateFactory();
     public static void setInstance(DayDateFactory factory) {
       DayDateFactory.factory = factory;
     }
 
     protected abstract DayDate _makeDate(int ordinal);
     protected abstract DayDate _makeDate(int day, DayDate.Month month, int year);
     protected abstract DayDate _makeDate(int day, int month, int year);
     protected abstract DayDate _makeDate(java.util.Date date);
     protected abstract int _getMinimumYear();
     protected abstract int _getMaximumYear();
 
     public static DayDate makeDate(int ordinal) {
       return factory._makeDate(ordinal);
     }
     public static DayDate makeDate(int day, DayDate.Month month, int year) {
       return factory._makeDate(day, month, year);
     }
 
     public static DayDate makeDate(int day, int month, int year) {
       return factory._makeDate(day, month, year);
     }
 
     public static DayDate makeDate(java.util.Date date) {
       return factory._makeDate(date);
     }
 
     public static int getMinimumYear() {
       return factory._getMinimumYear();
     }
 
     public static int getMaximumYear() {
       return factory._getMaximumYear();
     }
   }

This factory class replaces the createInstance methods with makeDate methods, which improves the names quite a bit [N1]. It defaults to a SpreadsheetDateFactory but can be changed at any time to use a different factory. The static methods that delegate to abstract methods use a combination of the SINGLETON,4 DECORATOR,5 and ABSTRACT FACTORY patterns that I have found to be useful.

这个工厂类用 makeDate 方法替换了 createInstance 方法,名字改善了不少 [N1]。它默认使用 SpreadsheetDateFactory,但可以随时更改为使用不同的工厂。将静态方法委托给抽象方法的这种做法,结合了单例模式(SINGLETON)^4、装饰器模式(DECORATOR)^5 和抽象工厂模式,我发现这非常有用。

The SpreadsheetDateFactory looks like this.

SpreadsheetDateFactory 看起来是这样的:

java
   public class SpreadsheetDateFactory extends DayDateFactory {
     public DayDate _makeDate(int ordinal) {
       return new SpreadsheetDate(ordinal);
     }
 
     public DayDate _makeDate(int day, DayDate.Month month, int year) {
       return new SpreadsheetDate(day, month, year);
     }
 
     public DayDate _makeDate(int day, int month, int year) {
       return new SpreadsheetDate(day, month, year);
     }
 
     public DayDate _makeDate(Date date) {
       final GregorianCalendar calendar = new GregorianCalendar();
       calendar.setTime(date);
       return new SpreadsheetDate(
         calendar.get(Calendar.DATE),
         DayDate.Month.make(calendar.get(Calendar.MONTH) + 1),
         calendar.get(Calendar.YEAR));
     }

     protected int _getMinimumYear() {
       return SpreadsheetDate.MINIMUM_YEAR_SUPPORTED;
     }
 
     protected int _getMaximumYear() {
       return SpreadsheetDate.MAXIMUM_YEAR_SUPPORTED;
     }
   }

As you can see, I have already moved the MINIMUM_YEAR_SUPPORTED and MAXIMUM_YEAR_SUPPORTED variables into SpreadsheetDate, where they belong [G6].

如你所见,我已经将 MINIMUM_YEAR_SUPPORTED 和 MAXIMUM_YEAR_SUPPORTED 变量移入了它们所属的 SpreadsheetDate 中 [G6]。

The next issue in DayDate are the day constants beginning at line 109. These should really be another enum [J3]. We've seen this pattern before, so I won't repeat it here. You'll see it in the final listings.

DayDate 中的下一个问题是从第 109 行开始的日期常量。这些真的应该用另一个枚举来表示 [J3]。我们已经见过这种模式了,所以这里不再重复。你会在最终的代码清单中看到它。

Next, we see a series of tables starting with LAST_DAY_OF_MONTH at line 140. My first issue with these tables is that the comments that describe them are redundant [C3]. Their names are sufficient. So I'm going to delete the comments.

接下来,我们看到从第 140 行 LAST_DAY_OF_MONTH 开始的一系列表格。我对这些表格的第一个问题是描述它们的注释是多余的 [C3]。它们的名字已经足够说明问题了。所以我打算删除这些注释。

There seems to be no good reason that this table isn't private [G8], because there is a static function lastDayOfMonth that provides the same data.

这个表格没有理由不设为 private [G8],因为已经有一个静态函数 lastDayOfMonth 提供了相同的数据。

The next table, AGGREGATE_DAYS_TO_END_OF_MONTH, is a bit more mysterious because it is not used anywhere in the JCommon framework [G9]. So I deleted it.

下一个表格 AGGREGATE_DAYS_TO_END_OF_MONTH 更加神秘,因为它在 JCommon 框架中根本没被使用过 [G9]。所以我删除了它。

The same goes for LEAP_YEAR_AGGREGATE_DAYS_TO_END_OF_MONTH.

LEAP_YEAR_AGGREGATE_DAYS_TO_END_OF_MONTH 也是如此。

The next table, AGGREGATE_DAYS_TO_END_OF_PRECEDING_MONTH, is used only in Spread-sheetDate (line 434 and line 473). This begs the question of whether it should be moved to SpreadsheetDate. The argument for not moving it is that the table is not specific to any particular implementation [G6]. On the other hand, no implementation other than SpreadsheetDate actually exists, and so the table should be moved close to where it is used [G10].

下一个表格 AGGREGATE_DAYS_TO_END_OF_PRECEDING_MONTH 只在 SpreadsheetDate 中使用(第 434 行和第 473 行)。这就引出了一个问题:是否应该把它移到 SpreadsheetDate 中。不移动它的论据是,这个表格并非针对任何特定实现 [G6]。但另一方面,实际上除了 SpreadsheetDate 之外没有其他实现存在,所以应该把表格移到靠近使用它的地方 [G10]。

What settles the argument for me is that to be consistent [G11], we should make the table private and expose it through a function like julianDateOfLastDayOfMonth. Nobody seems to need a function like that. Moreover, the table can be moved back to DayDate easily if any new implementation of DayDate needs it. So I moved it.

让我做出最终决定的是,为了保持一致性 [G11],我们应该将表格设为 private,并通过类似 julianDateOfLastDayOfMonth 的函数来暴露它。但似乎没有人需要这样的函数。而且,如果 DayDate 的任何新实现需要这个表格,它可以很容易地移回 DayDate。所以我把它移走了。

The same goes for the table, LEAP_YEAR_AGGREGATE_DAYS_TO_END_OF_MONTH.

LEAP_YEAR_AGGREGATE_DAYS_TO_END_OF_MONTH 表格也是如此。

Next, we see three sets of constants that can be turned into enums (lines 162-205). The first of the three selects a week within a month. I changed it into an enum named WeekInMonth.

接下来,我们看到三组可以转换为枚举的常量(第 162-205 行)。第一组用于选择月份中的某一周。我将它改为名为 WeekInMonth 的枚举。

java
   public enum WeekInMonth {
       FIRST(1), SECOND(2), THIRD(3), FOURTH(4), LAST(0);
       public final int index;
 
       WeekInMonth(int index) {
         this.index = index;
       }
     }

The second set of constants (lines 177-187) is a bit more obscure. The INCLUDE_NONE, INCLUDE_FIRST, INCLUDE_SECOND, and INCLUDE_BOTH constants are used to describe whether the defining end-point dates of a range should be included in that range. Mathematically, this is described using the terms "open interval," "half-open interval," and "closed interval." I think it is clearer using the mathematical nomenclature [N3], so I changed it to an enum named DateInterval with CLOSED, CLOSED_LEFT, CLOSED_RIGHT, and OPEN enumerators.

第二组常量(第 177-187 行)更为晦涩。INCLUDE_NONE、INCLUDE_FIRST、INCLUDE_SECOND 和 INCLUDE_BOTH 常量用于描述一个范围的定义端点日期是否应包含在该范围内。在数学上,这用"开区间"、"半开区间"和"闭区间"这些术语来描述。我认为使用数学术语更清晰 [N3],所以我将其改为名为 DateInterval 的枚举,包含 CLOSED、CLOSED_LEFT、CLOSED_RIGHT 和 OPEN 四个枚举值。

The third set of constants (lines 189-205) describe whether a search for a particular day of the week should result in the last, next, or nearest instance. Deciding what to call this is difficult at best. In the end, I settled for WeekdayRange with LAST, NEXT, and NEAREST enumerators.

第三组常量(第 189-205 行)描述搜索某个星期几时应返回最近的、下一个还是最近的实例。给它起个合适的名字绝非易事。最终,我选择了 WeekdayRange,包含 LAST、NEXT 和 NEAREST 三个枚举值。

You might not agree with the names I've chosen. They make sense to me, but they may not make sense to you. The point is that they are now in a form that makes them easy to change [J3]. They aren't passed as integers anymore; they are passed as symbols. I can use the "change name" function of my IDE to change the names, or the types, without worrying that I missed some -1 or 2 somewhere in the code or that some int argument declaration is left poorly described.

你可能不认同我选择的名字。它们对我来说很有意义,但对你来说可能并非如此。关键在于它们现在采用了一种易于修改的形式 [J3]。它们不再作为整数传递,而是作为符号传递。我可以使用 IDE 的"重命名"功能来更改名字或类型,而不用担心在代码某处遗漏了某个 -1 或 2,也不用担心某个 int 参数声明缺乏描述。

The description field at line 208 does not seem to be used by anyone. I deleted it along with its accessor and mutator [G9].

第 208 行的 description 字段似乎没有人使用。我把它连同它的访问器和修改器一起删除了 [G9]。

I also deleted the degenerate default constructor at line 213 [G12]. The compiler will generate it for us.

我还删除了第 213 行退化的默认构造函数 [G12]。编译器会自动为我们生成。

We can skip over the isValidWeekdayCode method (lines 216-238) because we deleted it when we created the Day enumeration.

我们可以跳过 isValidWeekdayCode 方法(第 216-238 行),因为我们在创建 Day 枚举时已经删除了它。

This brings us to the stringToWeekdayCode method (lines 242-270). Javadocs that don't add much to the method signature are just clutter [C3],[G12]. The only value this Javadoc adds is the description of the -1 return value. However, because we changed to the Day enumeration, the comment is actually wrong [C2]. The method now throws an IllegalArgumentException. So I deleted the Javadoc.

接下来看 stringToWeekdayCode 方法(第 242-270 行)。不能为方法签名增添多少信息的 Javadoc 只会造成杂乱 [C3],[G12]。这个 Javadoc 唯一的价值是描述了 -1 的返回值。然而,由于我们改用了 Day 枚举,这个注释实际上是错误的 [C2]。该方法现在抛出的是 IllegalArgumentException。所以我删除了 Javadoc。

I also deleted all the final keywords in arguments and variable declarations. As far as I could tell, they added no real value but did add to the clutter [G12]. Eliminating final flies in the face of some conventional wisdom. For example, Robert Simmons6 strongly recommends us to ". . . spread final all over your code." Clearly I disagree. I think that there are a few good uses for final, such as the occasional final constant, but otherwise the keyword adds little value and creates a lot of clutter. Perhaps I feel this way because the kinds of errors that final might catch are already caught by the unit tests I write.

我还删除了参数和变量声明中的所有 final 关键字。据我判断,它们没有真正增加价值,反而增加了杂乱 [G12]。消除 final 违背了一些传统观念。例如,Robert Simmons[^6] 强烈建议我们"......在代码中到处使用 final。"显然我不同意。我认为 final 有一些好的用途,比如偶尔用来声明常量,但除此之外,这个关键字增加的价值很小,却制造了很多杂乱。也许我有这种感觉是因为 final 可能捕获的那类错误已经被我编写的单元测试捕获了。

[^6]: [Simmons04], p. 73.

I didn't care for the duplicate if statements [G5] inside the for loop (line 259 and line 263), so I connected them into a single if statement using the || operator. I also used the Day enumeration to direct the for loop and made a few other cosmetic changes.

我不喜欢 for 循环内重复的 if 语句 [G5](第 259 行和第 263 行),所以我用 || 运算符将它们合并为一个 if 语句。我还使用了 Day 枚举来控制 for 循环,并做了一些其他美化修改。

It occurred to me that this method does not really belong in DayDate. It's really the parse function of Day. So I moved it into the Day enumeration. However, that made the Day enumeration pretty large. Because the concept of Day does not depend on DayDate, I moved the Day enumeration outside of the DayDate class into its own source file [G13].

我意识到这个方法其实不属于 DayDate。它实际上是 Day 的解析函数。所以我把它移到了 Day 枚举中。然而,这使得 Day 枚举变得相当大。因为 Day 的概念不依赖于 DayDate,我把 Day 枚举从 DayDate 类中移出来,放到了它自己的源文件中 [G13]。

I also moved the next function, weekdayCodeToString (lines 272-286) into the Day enumeration and called it toString.

我还将下一个函数 weekdayCodeToString(第 272-286 行)移入了 Day 枚举,并将它命名为 toString。

java
   public enum Day {
     MONDAY(Calendar.MONDAY),
     TUESDAY(Calendar.TUESDAY),
     WEDNESDAY(Calendar.WEDNESDAY),s
     THURSDAY(Calendar.THURSDAY),
     FRIDAY(Calendar.FRIDAY),
     SATURDAY(Calendar.SATURDAY),
     SUNDAY(Calendar.SUNDAY);
 
     public final int index;
     private static DateFormatSymbols dateSymbols = new DateFormatSymbols();
 
     Day(int day) {
       index = day;
     }
 
     public static Day make(int index) throws IllegalArgumentException {
       for (Day d : Day.values())
         if (d.index == index)
           return d;
       throw new IllegalArgumentException(
         String.format("Illegal day index: %d.", index));
     }
 
     public static Day parse(String s) throws IllegalArgumentException {
       String[] shortWeekdayNames =
         dateSymbols.getShortWeekdays();
       String[] weekDayNames =
         dateSymbols.getWeekdays();
 
       s = s.trim();
       for (Day day : Day.values()) {
         if (s.equalsIgnoreCase(shortWeekdayNames[day.index]) ||
             s.equalsIgnoreCase(weekDayNames[day.index])) {
           return day;
         }
       }
       throw new IllegalArgumentException(
         String.format("%s is not a valid weekday string", s));
      }
 
      public String toString() {
        return dateSymbols.getWeekdays()[index];
      }
    }

There are two getMonths functions (lines 288-316). The first calls the second. The second is never called by anyone but the first. Therefore, I collapsed the two into one and vastly simplified them [G9],[G12],[F4]. Finally, I changed the name to be a bit more self-descriptive [N1].

有两个 getMonths 函数(第 288-316 行)。第一个调用第二个,第二个除了第一个之外没有人调用。因此,我将两者合并为一个,并大幅简化了它们 [G9],[G12],[F4]。最后,我将名字改得更具自描述性 [N1]。

java
   public static String[] getMonthNames() {
     return dateFormatSymbols.getMonths();
   }

The isValidMonthCode function (lines 326-346) was made irrelevant by the Month enum, so I deleted it [G9].

isValidMonthCode 函数(第 326-346 行)因为 Month 枚举的存在而变得多余,所以我删除了它 [G9]。

The monthCodeToQuarter function (lines 356-375) smells of FEATURE ENVY7 [G14] and probably belongs in the Month enum as a method named quarter. So I replaced it.

monthCodeToQuarter 函数(第 356-375 行)散发出特性依恋(FEATURE ENVY)^7 的味道 [G14],它可能应该作为 Month 枚举中一个名为 quarter 的方法。所以我做了替换。

java
   public int quarter() {
     return 1 + (index-1)/3;
   }

This made the Month enum big enough to be in its own class. So I moved it out of DayDate to be consistent with the Day enum [G11],[G13].

这使得 Month 枚举大到足以成为独立的类。所以我将它移出 DayDate,与 Day 枚举保持一致 [G11],[G13]。

The next two methods are named monthCodeToString (lines 377-426). Again, we see the pattern of one method calling its twin with a flag. It is usually a bad idea to pass a flag as an argument to a function, especially when that flag simply selects the format of the output [G15]. I renamed, simplified, and restructured these functions and moved them into the Month enum [N1],[N3],[C3],[G14].

接下来的两个方法名为 monthCodeToString(第 377-426 行)。我们再次看到了一个方法通过标志参数调用其孪生兄弟的模式。将标志作为参数传递给函数通常是个坏主意,尤其是当该标志只是用来选择输出格式的时候 [G15]。我重命名、简化并重构了这些函数,将它们移入了 Month 枚举 [N1],[N3],[C3],[G14]。

java
   public String toString() {
     return dateFormatSymbols.getMonths()[index - 1];
   }
 
   public String toShortString() {
     return dateFormatSymbols.getShortMonths()[index - 1];
   }

The next method is stringToMonthCode (lines 428-472). I renamed it, moved it into the Month enum, and simplified it [N1],[N3],[C3],[G14],[G12].

下一个方法是 stringToMonthCode(第 428-472 行)。我重命名了它,将它移入 Month 枚举,并简化了它 [N1],[N3],[C3],[G14],[G12]。

java
   public static Month parse(String s) {
     s = s.trim();
     for (Month m : Month.values())
       if (m.matches(s))
         return m;
 
     try {
       return make(Integer.parseInt(s));
     }
     catch (NumberFormatException e) {}
     throw new IllegalArgumentException("Invalid month " + s);
   }

   private boolean matches(String s) {
     return s.equalsIgnoreCase(toString()) ||
            s.equalsIgnoreCase(toShortString());
   }

The isLeapYear method (lines 495-517) can be made a bit more expressive [G16].

isLeapYear 方法(第 495-517 行)可以写得更具表现力一些 [G16]。

java
   public static boolean isLeapYear(int year) {
     boolean fourth = year % 4 == 0;
     boolean hundredth = year % 100 == 0;
     boolean fourHundredth = year % 400 == 0;
     return fourth && (!hundredth || fourHundredth);
   }

The next function, leapYearCount (lines 519-536) doesn't really belong in DayDate. Nobody calls it except for two methods in SpreadsheetDate. So I pushed it down [G6].

下一个函数 leapYearCount(第 519-536 行)并不真正属于 DayDate。除了 SpreadsheetDate 中的两个方法之外没有人调用它。所以我把它下移了 [G6]。

The lastDayOfMonth function (lines 538-560) makes use of the LAST_DAY_OF_MONTH array. This array really belongs in the Month enum [G17], so I moved it there. I also simplified the function and made it a bit more expressive [G16].

lastDayOfMonth 函数(第 538-560 行)使用了 LAST_DAY_OF_MONTH 数组。这个数组实际上属于 Month 枚举 [G17],所以我把它移了过去。我还简化了函数,使其更具表现力 [G16]。

java
   public static int lastDayOfMonth(Month month, int year) {
     if (month == Month.FEBRUARY && isLeapYear(year))
       return month.lastDay() + 1;
      else
       return month.lastDay();
   }

Now things start to get a bit more interesting. The next function is addDays (lines 562-576). First of all, because this function operates on the variables of DayDate, it should not be static [G18]. So I changed it to an instance method. Second, it calls the function toSerial. This function should be renamed toOrdinal [N1]. Finally, the method can be simplified.

现在事情开始变得更有意思了。下一个函数是 addDays(第 562-576 行)。首先,因为这个函数操作的是 DayDate 的变量,它不应该是静态的 [G18]。所以我将它改为实例方法。其次,它调用了 toSerial 函数。这个函数应该重命名为 toOrdinal [N1]。最后,这个方法可以被简化。

java
   public DayDate addDays(int days) {
     return DayDateFactory.makeDate(toOrdinal() + days);
   }

The same goes for addMonths (lines 578-602). It should be an instance method [G18]. The algorithm is a bit complicated, so I used EXPLAINING TEMPORARY VARIABLES8 [G19] to make it more transparent. I also renamed the method getYYY to getYear [N1].

addMonths(第 578-602 行)也是如此。它应该是实例方法 [G18]。算法有点复杂,所以我使用了解释性临时变量(EXPLAINING TEMPORARY VARIABLES)^8 [G19] 来使它更加透明。我还将方法 getYYY 重命名为 getYear [N1]。

java
   public DayDate addMonths(int months) {
     int thisMonthAsOrdinal = 12 * getYear() + getMonth().index - 1;
     int resultMonthAsOrdinal = thisMonthAsOrdinal + months;
     int resultYear = resultMonthAsOrdinal / 12;
     Month resultMonth = Month.make(resultMonthAsOrdinal % 12 + 1);

     int lastDayOfResultMonth = lastDayOfMonth(resultMonth, resultYear);
     int resultDay = Math.min(getDayOfMonth(), lastDayOfResultMonth);
     return DayDateFactory.makeDate(resultDay, resultMonth, resultYear);
   }

The addYears function (lines 604-626) provides no surprises over the others.

addYears 函数(第 604-626 行)与其他函数相比没什么意外之处。

java
   public DayDate plusYears(int years) {
     int resultYear = getYear() + years;
     int lastDayOfMonthInResultYear = lastDayOfMonth(getMonth(), resultYear);
     int resultDay = Math.min(getDayOfMonth(), lastDayOfMonthInResultYear);
     return DayDateFactory.makeDate(resultDay, getMonth(), resultYear);
   }

There is a little itch at the back of my mind that is bothering me about changing these methods from static to instance. Does the expression date.addDays(5) make it clear that the date object does not change and that a new instance of DayDate is returned? Or does it erroneously imply that we are adding five days to the date object? You might not think that is a big problem, but a bit of code that looks like the following can be very deceiving [G20].

在我脑海深处一直有个小困扰,关于将这些方法从静态改为实例方法这件事。表达式 date.addDays(5) 能清楚地表明 date 对象不会改变,而是返回一个新的 DayDate 实例吗?还是会错误地暗示我们正在给 date 对象加上五天?你可能不觉得这是个大问题,但像下面这样的代码会非常具有欺骗性 [G20]。

java
   DayDate date = DateFactory.makeDate(5, Month.DECEMBER, 1952);
   date.addDays(7); // bump date by one week.

Someone reading this code would very likely just accept that addDays is changing the date object. So we need a name that breaks this ambiguity [N4]. So I changed the names to plusDays and plusMonths. It seems to me that the intent of the method is captured nicely by

读这段代码的人很可能会直接接受 addDays 在改变 date 对象这一事实。所以我们需要一个能消除这种歧义的名字 [N4]。于是我将名字改为 plusDays 和 plusMonths。在我看来,方法的意图可以通过这样的方式很好地表达:

java
   DayDate date = oldDate.plusDays(5);

whereas the following doesn't read fluidly enough for a reader to simply accept that the date object is changed:

而下面这种写法读起来就不够流畅,不足以让读者自然地接受 date 对象被修改了:

java
   date.plusDays(5);

The algorithms continue to get more interesting. getPreviousDayOfWeek (lines 628-660) works but is a bit complicated. After some thought about what was really going on [G21], I was able to simplify it and use EXPLAINING TEMPORARY VARIABLES [G19] to make it clearer. I also changed it from a static method to an instance method [G18], and got rid of the duplicate instance method [G5] (lines 997-1008).

算法继续变得更加有趣。getPreviousDayOfWeek(第 628-660 行)能正常工作,但有点复杂。经过对真正发生了什么的深入思考 [G21],我得以简化它,并使用解释性临时变量 [G19] 使其更加清晰。我还把它从静态方法改为实例方法 [G18],并去除了重复的实例方法 [G5](第 997-1008 行)。

java
   public DayDate getPreviousDayOfWeek(Day targetDayOfWeek) {
     int offsetToTarget = targetDayOfWeek.index - getDayOfWeek().index;
     if (offsetToTarget >= 0)
       offsetToTarget -= 7;
     return plusDays(offsetToTarget);
   }

The exact same analysis and result occurred for getFollowingDayOfWeek (lines 662-693).

getFollowingDayOfWeek(第 662-693 行)也经历了完全相同的分析过程和结果。

java
   public DayDate getFollowingDayOfWeek(Day targetDayOfWeek) {
       int offsetToTarget = targetDayOfWeek.index - getDayOfWeek().index;
       if (offsetToTarget <= 0)

         offsetToTarget += 7;
       return plusDays(offsetToTarget);
     }

The next function is getNearestDayOfWeek (lines 695-726), which we corrected back on page 270. But the changes I made back then aren't consistent with the current pattern in the last two functions [G11]. So I made it consistent and used some EXPLAINING TEMPORARY VARIABLES [G19] to clarify the algorithm.

下一个函数是 getNearestDayOfWeek(第 695-726 行),我们在前面第 270 页已经修正过它。但那时我所做的修改与最后两个函数中的当前模式不一致 [G11]。所以我使它保持一致,并使用了一些解释性临时变量 [G19] 来阐明算法。

java
   public DayDate getNearestDayOfWeek(final Day targetDay) {
       int offsetToThisWeeksTarget = targetDay.index - getDayOfWeek().index;
       int offsetToFutureTarget = (offsetToThisWeeksTarget + 7) % 7;
       int offsetToPreviousTarget = offsetToFutureTarget - 7;
 
       if (offsetToFutureTarget > 3)
         return plusDays(offsetToPreviousTarget);
       else
         return plusDays(offsetToFutureTarget);
     }

The getEndOfCurrentMonth method (lines 728-740) is a little strange because it is an instance method that envies [G14] its own class by taking a DayDate argument. I made it a true instance method and clarified a few names.

getEndOfCurrentMonth 方法(第 728-740 行)有点奇怪,因为它是一个实例方法,却通过接收一个 DayDate 参数来依恋 [G14] 自己所在的类。我把它改成了真正的实例方法,并澄清了一些名字。

java
   public DayDate getEndOfMonth() {
       Month month = getMonth();
       int year = getYear();
       int lastDay = lastDayOfMonth(month, year);
       return DayDateFactory.makeDate(lastDay, month, year);
     }

Refactoring weekInMonthToString (lines 742-761) turned out to be very interesting indeed. Using the refactoring tools of my IDE, I first moved the method to the WeekInMonth enum that I created back on page 275. Then I renamed the method to toString. Next, I changed it from a static method to an instance method. All the tests still passed. (Can you guess where I am going?)

重构 weekInMonthToString(第 742-761 行)的结果非常有趣。使用 IDE 的重构工具,我首先将方法移到了我在第 275 页创建的 WeekInMonth 枚举中。然后将方法重命名为 toString。接着,我将它从静态方法改为实例方法。所有测试仍然通过。(你能猜到我接下来要做什么吗?)

Next, I deleted the method entirely! Five asserts failed (lines 411-415, Listing B-4, page 374). I changed these lines to use the names of the enumerators (FIRST, SECOND, ...). All the tests passed. Can you see why? Can you also see why each of these steps was necessary? The refactoring tool made sure that all previous callers of weekInMonthToString now called toString on the weekInMonth enumerator because all enumerators implement toString to simply return their names....

接下来,我彻底删除了这个方法!五个断言失败了(第 411-415 行,代码清单 B-4,第 374 页)。我将这些行改为使用枚举值的名字(FIRST, SECOND, ...)。所有测试都通过了。你能看出为什么吗?你也能看出为什么每一步都是必要的吗?重构工具确保了 weekInMonthToString 的所有调用者现在都调用 weekInMonth 枚举器上的 toString,因为所有枚举器都实现了 toString 来简单地返回它们的名字......

Unfortunately, I was a bit too clever. As elegant as that wonderful chain of refactorings was, I finally realized that the only users of this function were the tests I had just modified, so I deleted the tests.

不幸的是,我有点聪明过头了。尽管那一连串精彩的重构很优雅,但我最终意识到这个函数的唯一使用者就是我刚刚修改的那些测试,所以我删除了这些测试。

Fool me once, shame on you. Fool me twice, shame on me! So after determining that nobody other than the tests called relativeToString (lines 765-781), I simply deleted the function and its tests.

骗我一次,是你的错;骗我两次,就是我的错了!所以在确定除了测试之外没有人调用 relativeToString(第 765-781 行)之后,我直接删除了这个函数和它的测试。

We have finally made it to the abstract methods of this abstract class. And the first one is as appropriate as they come: toSerial (lines 838-844). Back on page 279 I had changed the name to toOrdinal. Having looked at it in this context, I decided the name should be changed to getOrdinalDay.

我们终于来到了这个抽象类的抽象方法。第一个就恰如其分:toSerial(第 838-844 行)。在第 279 页我已经将它改名为 toOrdinal。在这个上下文中重新审视之后,我决定将名字改为 getOrdinalDay。

The next abstract method is toDate (lines 838-844). It converts a DayDate to a java.util.Date. Why is this method abstract? If we look at its implementation in SpreadsheetDate (lines 198-207, Listing B-5, page 382), we see that it doesn't depend on anything in the implementation of that class [G6]. So I pushed it up.

下一个抽象方法是 toDate(第 838-844 行)。它将 DayDate 转换为 java.util.Date。为什么这个方法是抽象的?如果我们看看它在 SpreadsheetDate 中的实现(第 198-207 行,代码清单 B-5,第 382 页),会发现它并不依赖于该类实现中的任何东西 [G6]。所以我将它上移了。

The getYYYY, getMonth, and getDayOfMonth methods are nicely abstract. However, the getDayOfWeek method is another one that should be pulled up from SpreadSheetDate because it doesn't depend on anything that can't be found in DayDate [G6]. Or does it?

getYYYY、getMonth 和 getDayOfMonth 方法都是恰到好处的抽象方法。然而,getDayOfWeek 方法是另一个应该从 SpreadsheetDate 上移的方法,因为它不依赖于 DayDate 中找不到的任何东西 [G6]。真的如此吗?

If you look carefully (line 247, Listing B-5, page 382), you'll see that the algorithm implicitly depends on the origin of the ordinal day (in other words, the day of the week of day 0). So even though this function has no physical dependencies that couldn't be moved to DayDate, it does have a logical dependency.

如果你仔细看(第 247 行,代码清单 B-5,第 382 页),你会发现该算法隐式地依赖于序数日期的原点(换句话说,第 0 天是星期几)。所以尽管这个函数没有无法移到 DayDate 的物理依赖,但它确实有一个逻辑依赖。

Logical dependencies like this bother me [G22]. If something logical depends on the implementation, then something physical should too. Also, it seems to me that the algorithm itself could be generic with a much smaller portion of it dependent on the implementation [G6].

像这样的逻辑依赖让我很不舒服 [G22]。如果某个逻辑层面的东西依赖于实现,那么物理层面的也应该如此。而且在我看来,算法本身可以是通用的,只有很小一部分依赖于实现 [G6]。

So I created an abstract method in DayDate named getDayOfWeekForOrdinalZero and implemented it in SpreadsheetDate to return Day.SATURDAY. Then I moved the getDayOfWeek method up to DayDate and changed it to call getOrdinalDay and getDayOfWeekForOrdinal-Zero.

于是我在 DayDate 中创建了一个名为 getDayOfWeekForOrdinalZero 的抽象方法,并在 SpreadsheetDate 中实现它,让它返回 Day.SATURDAY。然后我将 getDayOfWeek 方法上移到 DayDate,并让它调用 getOrdinalDay 和 getDayOfWeekForOrdinalZero。

java
   public Day getDayOfWeek() {
       Day startingDay = getDayOfWeekForOrdinalZero();
       int startingOffset = startingDay.index - Day.SUNDAY.index;
       return Day.make((getOrdinalDay() + startingOffset) % 7 + 1);
     }

As a side note, look carefully at the comment on line 895 through line 899. Was this repetition really necessary? As usual, I deleted this comment along with all the others.

附带一提,仔细看看第 895 行到第 899 行的注释。这种重复真的有必要吗?像往常一样,我把这条注释连同其他注释一起删除了。

The next method is compare (lines 902-913). Again, this method is inappropriately abstract [G6], so I pulled the implementation up into DayDate. Also, the name does not communicate enough [N1]. This method actually returns the difference in days since the argument. So I changed the name to daysSince. Also, I noted that there weren't any tests for this method, so I wrote them.

下一个方法是 compare(第 902-913 行)。同样,这个方法的抽象程度不合适 [G6],所以我将实现上移到了 DayDate 中。而且名字的表意不够 [N1]。这个方法实际上返回的是与参数之间的天数差。所以我将它改名为 daysSince。我还注意到这个方法没有任何测试,于是我编写了测试。

The next six functions (lines 915-980) are all abstract methods that should be implemented in DayDate. So I pulled them all up from SpreadsheetDate.

接下来的六个函数(第 915-980 行)都是抽象方法,应该在 DayDate 中实现。所以我把它们全部从 SpreadsheetDate 上移了。

The last function, isInRange (lines 982-995) also needs to be pulled up and refactored. The switch statement is a bit ugly [G23] and can be replaced by moving the cases into the DateInterval enum.

最后一个函数 isInRange(第 982-995 行)也需要上移并重构。switch 语句有点丑陋 [G23],可以通过将各个 case 移入 DateInterval 枚举来替换。

java
   public enum DateInterval {
       OPEN {
         public boolean isIn(int d, int left, int right) {
           return d > left && d < right;
         }
       },
       CLOSED_LEFT {
         public boolean isIn(int d, int left, int right) {
           return d >= left && d < right;
         }
       },
       CLOSED_RIGHT {
         public boolean isIn(int d, int left, int right) {
           return d > left && d <= right;
         }
       },
       CLOSED {
         public boolean isIn(int d, int left, int right) {
           return d >= left && d <= right;
         }
       };
 
       public abstract boolean isIn(int d, int left, int right);
     }

   public boolean isInRange(DayDate d1, DayDate d2, DateInterval interval) {
       int left = Math.min(d1.getOrdinalDay(), d2.getOrdinalDay());
       int right = Math.max(d1.getOrdinalDay(), d2.getOrdinalDay());
       return interval.isIn(getOrdinalDay(), left, right);
     }

That brings us to the end of DayDate. So now we'll make one more pass over the whole class to see how well it flows.

这就完成了对 DayDate 的分析。现在我们再对整个类做最后一遍审查,看看它的流畅程度如何。

First, the opening comment is long out of date, so I shortened and improved it [C2].

首先,开头的注释早已过时,所以我缩短并改进了它 [C2]。

Next, I moved all the remaining enums out into their own files [G12].

接着,我将所有剩余的枚举移到了各自的文件中 [G12]。

Next, I moved the static variable (dateFormatSymbols) and three static methods (getMonthNames, isLeapYear, lastDayOfMonth) into a new class named DateUtil [G6].

然后,我将静态变量(dateFormatSymbols)和三个静态方法(getMonthNames、isLeapYear、lastDayOfMonth)移入了一个名为 DateUtil 的新类中 [G6]。

I moved the abstract methods up to the top where they belong [G24].

我将抽象方法移到了它们应在的位置——顶部 [G24]。

I changed Month.make to Month.fromInt [N1] and did the same for all the other enums. I also created a toInt() accessor for all the enums and made the index field private.

我将 Month.make 改为 Month.fromInt [N1],对所有其他枚举也做了同样的修改。我还为所有枚举创建了 toInt() 访问器,并将 index 字段设为 private。

There was some interesting duplication [G5] in plusYears and plusMonths that I was able to eliminate by extracting a new method named correctLastDayOfMonth, making the all three methods much clearer.

在 plusYears 和 plusMonths 中有一些有趣的重复 [G5],我通过提取一个名为 correctLastDayOfMonth 的新方法消除了这些重复,使得这三个方法都更加清晰。

I got rid of the magic number 1 [G25], replacing it with Month.JANUARY.toInt() or Day.SUNDAY.toInt(), as appropriate. I spent a little time with SpreadsheetDate, cleaning up the algorithms a bit. The end result is contained in Listing B-7, page 394, through Listing B-16, page 405.

我消除了魔法数字 1 [G25],根据情况替换为 Month.JANUARY.toInt() 或 Day.SUNDAY.toInt()。我花了一些时间处理 SpreadsheetDate,稍微清理了一下算法。最终结果包含在代码清单 B-7(第 394 页)到代码清单 B-16(第 405 页)中。

Interestingly the code coverage in DayDate has decreased to 84.9 percent! This is not because less functionality is being tested; rather it is because the class has shrunk so much that the few uncovered lines have a greater weight. DayDate now has 45 out of 53 executable statements covered by tests. The uncovered lines are so trivial that they weren't worth testing.

有趣的是,DayDate 的代码覆盖率下降到了 84.9%!这并不是因为测试的功能减少了,而是因为类缩小了太多,以至于少数未覆盖的行占据了更大的权重。DayDate 现在有 53 条可执行语句中的 45 条被测试覆盖。那些未覆盖的行太琐碎了,不值得测试。

16.3 CONCLUSION 结论

So once again we've followed the Boy Scout Rule. We've checked the code in a bit cleaner than when we checked it out. It took a little time, but it was worth it. Test coverage was increased, some bugs were fixed, the code was clarified and shrunk. The next person to look at this code will hopefully find it easier to deal with than we did. That person will also probably be able to clean it up a bit more than we did.

所以我们再一次践行了童子军军规。我们签入的代码比签出时干净了一些。这花了一些时间,但值得。测试覆盖率提高了,一些 bug 被修复了,代码被澄清和精简了。下一个查看这段代码的人应该会发现它比我们当时更容易理解。那个人或许还能比我们清理得更彻底一些。