Java设计问题:强制方法调用顺序

60

最近在一次面试中,有这样一个问题问到了我。

问题: 有一个类用于分析代码执行时间。类的结构如下:

Class StopWatch {

    long startTime;
    long stopTime;

    void start() {// set startTime}
    void stop() { // set stopTime}
    long getTime() {// return difference}

}

客户端需要创建一个StopWatch实例并相应地调用方法。用户代码可能会搞乱方法的使用,导致意外结果。例如,start()、stop()和getTime()的调用应按顺序进行。

这个类必须被“重新配置”,以便防止用户搞乱顺序。

我提议在调用 start() 之前调用 stop() 时使用自定义异常或进行一些 if/else 检查,但面试官并不满意。

是否有设计模式可以处理这种情况?

编辑:类成员和方法实现可以进行修改。


1
你被允许更改类吗?面试官是否表明他想在编译时静态地捕获程序员的错误? - aioobe
这三种方法应该保持不变,只是对程序员的接口。类成员和方法实现可以改变。 - Mohitt
21
您可以通过引入包装器来静态解决此问题:使用start()方法返回一个带有stop()方法的RunningStopWatchStoppedStopWatch。(然后可以通过将其设置为包级私有来防止直接使用底层的StopWatch。) - aioobe
在面试中,你是否详细阐述了你提出的解决方案,超出了问题本身的范围?因为我认为一个合理的面试官不会满足于还有很多需要澄清的地方,即使他/她正在寻找其中的任何一种解决方案。 - Bernhard Barker
12个回答

84

首先,实现自己的Java分析器是浪费时间的事情,因为已经有好的分析器可用(也许这就是问题背后的意图)。

如果您想在编译时强制执行正确的方法顺序,则必须在每个链中的方法中返回一些内容:

  1. start() 必须返回一个带有 stop 方法的 WatchStopper
  2. 然后,WatchStopper.stop() 必须返回一个具有 getResult() 方法的 WatchResult

当然,必须防止外部构建这些辅助类以及其他访问它们方法的方式。


6
在这种情况下,设计模式是将状态机应用于解决方案中,每个状态只允许可能的有效转换。 - Fuhrmanator
2
这样做的唯一缺点是状态逻辑在不同的类中分散。这是我不喜欢 GoF 的状态模式的部分。 https://dev59.com/El0Z5IYBdhLWcg3w8kFJ#30888746 在单个类中保留了所有逻辑。 - Fuhrmanator
19
哦,太好了。这就是我喜欢Java的原因。在一个月内,这个库的维护者正在开发AbstractWatchStopperFactory来支持不同类型的计时器。 - corsiKa
1
@immibis,类似于StopWatch => RunningStopWatch => StoppedStopWatch这样的命名可能更好(或者只是在后两个之间交替使用?)。这种命名描述了对象状态而不是可用的方法。在我看来,可用的方法从上下文中会比较明显,而这个名称则更明确地表示了对象的含义。 - Kat

37

通过对界面进行轻微更改,您可以使方法序列成为唯一可以在编译时调用的方法!

public class Stopwatch {
    public static RunningStopwatch createRunning() {
        return new RunningStopwatch();
    }
}

public class RunningStopwatch {
    private final long startTime;

    RunningStopwatch() {
        startTime = System.nanoTime();
    }

    public FinishedStopwatch stop() {
        return new FinishedStopwatch(startTime);
    }
}

public class FinishedStopwatch {
    private final long elapsedTime;

    FinishedStopwatch(long startTime) {
        elapsedTime = System.nanoTime() - startTime;
    }

    public long getElapsedNanos() {
        return elapsedTime;
    }
}

使用方法很简单 - 每个方法都会返回一个不同的类,其中仅包含当前适用的方法。基本上,秒表的状态封装在类型系统中。


评论中指出,即使使用上述设计,您仍然可以调用stop()两次。尽管我认为这是增加了附加值,但从理论上讲可能会自毁前程。那么,我能想到的唯一办法就是这样:

class Stopwatch {
    public static Stopwatch createRunning() {
        return new Stopwatch();
    }

    private final long startTime;

    private Stopwatch() {
        startTime = System.nanoTime();
    }

    public long getElapsedNanos() {
        return System.nanoTime() - startTime;
    }
}

这与任务不同,它省略了stop()方法,但这也有可能是一种良好的设计。一切都取决于精确的要求...


1
在一个 RunningStopwatch 实例上,什么阻止你调用两次 stop 方法? - Eran
4
没有问题,两个调用都是有效的——我认为这是一种增加了价值的方式,因为两个调用返回不同的 FinishedStopwatch 实例,这使得您可以将一个 RunningStopwatch 重复使用多次进行测量。虽然即使使用错误也很可能是正确的,但确实有可能以可怕的方式弄乱它。如果我们想要防范甚至这种情况,我们应该省略 stop() 方法,只在获得正在运行的秒表实例后提供 getElapsedNanos() - Petr Janeček
3
面试官可能是这个意思,但对于生产来说并不是正确的选择。 - usr
4
@usr 这不是吗?我有时会使用这个模式。它对于构建复杂对象非常有用,我称之为菜单模式(不知道它是否已经有名称),在实际应用中,例如Guava的MultimapBuilder中也被使用。我仍然很想知道你为什么认为它不适合生产。 - Petr Janeček
2
@Thomas,这又是一个高级设计问题。基本思想是静态工厂方法更加灵活,可以有意义的名称,更自然地支持API变化等。在这种情况下,它感觉很自然。其基本思想来自Josh Bloch的杰出著作《Effective Java》。 - Petr Janeček
显示剩余4条评论

23

我们通常使用Apache Commons StopWatch来检查其提供的模式。

当停表状态错误时,会抛出IllegalStateException(非法状态异常)。

stop

public void stop()

停止计时器。

此方法结束一个新的计时会话,允许检索时间。

Throws:

  • IllegalStateException - 如果StopWatch没有在运行中。

直截了当。


31
这与原帖有何不同?“我建议在调用start()之前调用stop()时使用自定义异常...,但面试官并不满意。” - Petr Janeček
2
vels4j,我猜这个方法正是Eran的建议。看来我走在了正确的道路上,但无法巩固我的答案。所以我猜如果需要维护方法调用的顺序,我们应该从状态图开始定义有效/无效的转换。 - Mohitt
1
如果我知道有基准解决方案,我会毫不犹豫地使用它。 - vels4j

19
也许他预料到了这种“重构”,问题根本不在于方法顺序:
class StopWatch {

   public static long runWithProfiling(Runnable action) {
      startTime = now;
      action.run();
      return now - startTime;
   }
}

AdamSkyWalker,我问他是否想要测量线程的执行时间,但他提到他正在寻找在单线程环境下的简单方法。 - Mohitt
6
但我的回答并不是关于线程的,Runnable只是一个接口,它提供了将方法执行封装起来并测量所花时间的可能性。它应该在单线程环境中使用。 - AdamSkywalker
1
我认为面试官期望的可能是这样的(尽管现在语法上不正确)。最好的回答可能是提供几个设计,并讨论每个设计的优缺点。 - Sebastian Reichelt

19

经过更多的思考

回想起来,他们似乎在寻找执行周围模式。它们通常用于执行诸如强制关闭流之类的操作。由于这一行代码,这也更相关:

有没有一种设计模式来处理这些情况?

这个想法是你给那个做“执行周围”工作的东西一些类去做某些事情。你可能会使用Runnable,但这不是必要的。Runnable最有意义,你很快就会明白为什么。)在你的StopWatch类中添加一个像这样的方法。

public long measureAction(Runnable r) {
    start();
    r.run();
    stop();
    return getTime();
}

您可以这样调用它。
StopWatch stopWatch = new StopWatch();
Runnable r = new Runnable() {
    @Override
    public void run() {
        // Put some tasks here you want to measure.
    }
};
long time = stopWatch.measureAction(r);

这使它变得非常简单。您不必担心在开始之前处理停止或人们忘记调用其中一个而不是另一个等问题。 Runnable 之所以好用,是因为:
  1. 标准的 Java 类,不是您自己的或第三方的
  2. 最终用户可以将他们需要完成的任何任务放入 Runnable 中。
(如果您要使用它来强制关闭流,则可以将需要与数据库连接一起执行的操作放入其中,以便最终用户不必担心如何打开和关闭它,并同时强制他们正确地关闭它。)
如果您想要,您可以创建一些 StopWatchWrapper 来代替修改 StopWatch。您还可以使 measureAction(Runnable) 不返回时间,并公开 getTime()
Java 8 调用它的方式更加简单。
StopWatch stopWatch = new StopWatch();
long time = stopWatch.measureAction(() - > {/* Measure stuff here */});

A third (hopefully final) thought: it seems what the interviewer was looking for and what is being upvoted the most is throwing exceptions based on state (e.g., if stop() is called before start() or start() after stop()). This is a fine practice and in fact, depending on the methods in StopWatch having a visibility other than private/protected, it's probably better to have than not have. My one issue with this is that throwing exceptions alone will not enforce a method call sequence.

For example, consider this:

class StopWatch {
    boolean started = false;
    boolean stopped = false;

    // ...

    public void start() {
        if (started) {
            throw new IllegalStateException("Already started!");
        }
        started = true;
        // ...
    }

    public void stop() {
        if (!started) {
            throw new IllegalStateException("Not yet started!");
        }
        if (stopped) {
            throw new IllegalStateException("Already stopped!");
        }
        stopped = true;
        // ...
    }

    public long getTime() {
        if (!started) {
            throw new IllegalStateException("Not yet started!");
        }
        if (!stopped) {
            throw new IllegalStateException("Not yet stopped!");
        }
        stopped = true;
        // ...
    }
}

Just because it's throwing IllegalStateException doesn't mean that the proper sequence is enforced, it just means improper sequences are denied (and I think we can all agree exceptions are annoying, luckily this is not a checked exception).

The only way I know to truly enforce that the methods are called correctly is to do it yourself with the execute around pattern or the other suggestions that do things like return RunningStopWatch and StoppedStopWatch that I presume have only one method, but this seems overly complex (and OP mentioned that the interface couldn't be changed, admittedly the non-wrapper suggestion I made does this though). So to the best of my knowledge there's no way to enforce the proper order without modifying the interface or adding more classes.

I guess it really depends on what people define "enforce a method call sequence" to mean. If only the exceptions are thrown then the below compiles

StopWatch stopWatch = new StopWatch();
stopWatch.getTime();
stopWatch.stop();
stopWatch.start();

True it won't run, but it just seems so much simpler to hand in a Runnable and make those methods private, let the other one relax and handle the pesky details yourself. Then there's no guess work. With this class it's obvious the order, but if there were more methods or the names weren't so obvious it can begin to be a headache.


原始答案

更多后见之明的编辑: OP在评论中提到,

"这三种方法应该保持完整,只是对程序员的接口。类成员和方法实现可以改变。"

所以下面的方法是错误的,因为它从接口中删除了一些内容。(从技术上讲,你可以将其实现为空方法,但这似乎是一个愚蠢的事情,也太令人困惑了。)如果没有限制,我有点喜欢这个答案,它似乎是另一种“傻瓜式”的方法,所以我会保留它。

对我来说,像这样的东西似乎很好。

class StopWatch {

    private final long startTime;

    public StopWatch() {
        startTime = ...
    }

    public long stop() {
        currentTime = ...
        return currentTime - startTime;
    }
}

我认为这样做的好处是,在对象创建过程中进行录制,因此不会被遗忘或顺序错误(如果不存在,则无法调用stop()方法)。
一个缺陷可能是stop()的命名。起初我想到了lap(),但这通常意味着重新启动或某种记录(或者至少是自上次圈/开始以来的记录)。也许read()会更好?这模仿了看秒表时间的动作。我选择stop()以保持与原始类的相似性。
唯一我不确定的是如何获取时间。老实说,这似乎是一个较小的细节。只要上述代码中的两个...以相同的方式获取当前时间,就应该没问题。

6
我建议采用以下内容:

我建议采用类似以下内容:

interface WatchFactory {
    Watch startTimer();
}

interface Watch {
    long stopTimer();
}

它将会被用于以下方式

 Watch watch = watchFactory.startTimer();

 // Do something you want to measure

 long timeSpentInMillis = watch.stopTimer();

您不能按错误的顺序调用任何内容。如果您两次调用stopTimer,每次都会得到有意义的结果(也许更好的做法是将其重命名为measure,并在每次调用时返回实际时间)。


5
当方法没有按正确顺序调用时抛出异常是常见的。例如,Threadstart 在第二次调用时会抛出 IllegalThreadStateException 异常。
您可能应该更好地解释实例如何知道方法是否按正确顺序调用。这可以通过引入状态变量并在每个方法开始时检查状态(并在必要时更新状态)来完成。

你也可以重复使用 startTime,例如在秒表启动之前将其设置为 -1 - aioobe
2
Eran,看来你是对的。也许他想要带有示例的具体答案。感谢您的建议。 - Mohitt
@aioobe 这当然是一个选项,但我不确定这已经足够了,因为当您停止表时,您可能不想重置起始和结束时间(因为 getTime 不会给您正确的时间),所以如果没有添加状态变量,您将无法区分已启动的表和已停止的表。 - Eran

3
使用秒表的原因可能是对时间有兴趣的实体与负责启动和停止计时间隔的实体不同。如果不是这样,那么使用不可变对象并允许代码随时查询停表已流逝的时间的模式可能比使用可变停表对象更好。
如果您的目的是捕获有关各种事情花费多少时间的数据,我建议您最好使用一个构建计时相关事件列表的类。这样的类可以提供一种生成和添加新的计时相关事件的方法,记录其创建时间的快照并提供指示其完成的方法。外部类还将提供一种检索迄今为止注册的所有计时事件列表的方法。
如果创建新计时事件的代码提供了表示其目的的参数,则在检查列表的结尾的代码可以确定是否正确完成了启动的所有事件,并标识没有完成的任何事件;它还可以确定是否完全包含在其他事件中或重叠但未包含在其中。因为每个事件都有自己独立的状态,所以关闭一个事件失败不会干扰任何后续事件或导致与它们相关的计时数据的任何丢失或损坏(例如,当应该停止停表时,停表被意外地保持运行)。
虽然肯定有可能拥有一个使用“开始”和“停止”方法的可变秒表类,但如果意图是将每个“停止”操作与特定的“开始”操作关联起来,则使“开始”操作返回必须被“停止”的对象不仅将确保这种关联,而且即使启动并放弃一个操作也可以实现明智的行为。

3

在Java 8中也可以使用Lambda表达式来实现。在这种情况下,您将您的函数传递给StopWatch类,然后告诉StopWatch执行该代码。

Class StopWatch {

    long startTime;
    long stopTime;

    private void start() {// set startTime}
    private void stop() { // set stopTime}
    void execute(Runnable r){
        start();
        r.run();
        stop();
    }
    long getTime() {// return difference}
}

2
请注意,此代码也可以使用不太简洁的匿名内部类语法或任何实现Runnable的方式(嵌套类、顶级类等)而无需使用Lambda。 - Max Nanasy
1
@Max 说得好,实际上应该写成“使用 Runnables 完成”而不是“使用 Java 8 中的 Lambdas 完成”。 - Captain Man

2

我知道这个问题已经有答案了,但是找不到一个使用接口来控制流程的构建器的答案,所以这里是我的解决方案: (请给接口命名比我更好 :p)

public interface StartingStopWatch {
    StoppingStopWatch start();
}

public interface StoppingStopWatch {
    ResultStopWatch stop();
}

public interface ResultStopWatch {
    long getTime();
}

public class StopWatch implements StartingStopWatch, StoppingStopWatch, ResultStopWatch {

    long startTime;
    long stopTime;

    private StopWatch() {
        //No instanciation this way
    }

    public static StoppingStopWatch createAndStart() {
        return new StopWatch().start();
    }

    public static StartingStopWatch create() {
        return new StopWatch();
    }

    @Override
    public StoppingStopWatch start() {
        startTime = System.currentTimeMillis();
        return this;
    }

    @Override
    public ResultStopWatch stop() {
        stopTime = System.currentTimeMillis();
        return this;
    }

    @Override
    public long getTime() {
        return stopTime - startTime;
    }

}

使用方法:

StoppingStopWatch sw = StopWatch.createAndStart();
//Do stuff
long time = sw.stop().getTime();

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接