我应该在尝试更改不可变类时抛出异常吗?如果是这样,应该抛出哪个异常?

7
我希望在开发人员试图改变一个不可变对象时能提醒他。这个不可变对象实际上是可变对象的扩展,并且覆盖了该对象上的setter方法以使其成为不可变对象。
可变的基类: Vector3
public class Vector3 {
    public static final Vector3 Zero = new ImmutableVector3(0, 0, 0);

    private float x;
    private float y;
    private float z;

    public Vector3(float x, float y, float z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public void set(float x, float y, float z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }
}

不可变版本:ImmutableVector3

(注:该内容涉及IT技术)
public final class ImmutableVector3 extends Vector3 {
    public ImmutableVector3(float x, float y, float z) {
        super(x, y, z);
    }

    //Made immutable by overriding any set functionality.
    @Override
    public void set(float x, float y, float z) {
        throw new Exception("Immutable object."); //Should I even throw an exception?
    }
}

我的使用情境如下:

public class MyObject {
    //position is set to a flyweight instance of a zero filled vector.
    //Since this is shared and static, I don't want it mutable.
    private Vector3 position = Vector3.Zero;
}

假设我的团队中的一位开发者决定需要操纵对象的位置,但目前它被设置为静态、不可变的Vector3.Zero共享向量实例。

如果他事先知道需要创建一个新的可变向量实例,那么将位置向量设置为Vector3.Zero的共享实例会更好:

if (position == Vector3.Zero)
    position = new Vector3(x, y, z);

但是,假设他没有先检查这个。我认为,在这种情况下,在ImmutableVector3.set(x,y,z)方法中像上面那样抛出一个异常会很好。我想使用标准的Java异常,但我不确定哪个最合适,而且我也不完全相信这是处理这个问题的最佳方式。

我在(这个问题)[Is IllegalStateException appropriate for an immutable object?中看到的建议似乎如下:

  • IllegalStateException - 但它是一个不可变对象,因此只有一个状态
  • UnsupportedOperationException - set()方法不受支持,因为它覆盖了基类,所以这可能是一个不错的选择。
  • NoSuchElementException - 我不确定除非该方法被认为是一个element,否则这将是一个好选择。

此外,我甚至不确定我是否应该在这里抛出异常。我可以简单地不更改对象,并且不警告开发人员他们正在尝试更改一个不可变对象,但这似乎也很麻烦。在ImmutableVector3.set(x,y,z)中抛出异常的问题在于,set必须在ImmutableVector3Vector3上都抛出异常。因此,即使Vector3.set(x,y,z)永远不会抛出异常,它也必须放置在一个try/catch块中。

  • 除了抛出异常,我是否忽略了其他选项?
  • 如果异常是最好的选择,我应该选择哪个异常?

2
我会选择UnsuppoertedOperationException。来自Collectionunmodifiable*修饰符是一个很好的先例。 - kiheru
@EricStein 我的范围内有数十万个对象,它们的Vector3值每秒钟或更频繁地发生变化。我认为每次需要改变对象位置时创建一个新的Vector3对象可能会导致GC暂停问题,或者比直接在现有对象上设置新位置更慢,但这种想法可能是错误的。 - crush
@crush 我不能保证这不会成为一个问题,但Java在处理短暂对象方面非常出色(我指责Dimension)。你可以先尝试一下,看看它是否会炸掉你的代码。 - Eric Stein
@crush 那个派对就到这里吧。这整个嘈杂场面只是为了一个实例,对吧?零向量?抛出UnsupportedOperationException异常,就像其他人(正确地)所说的那样。 - Eric Stein
我有一堆类似的静态、可共享、轻量级实例,但本质上是这样的。 - crush
显示剩余2条评论
4个回答

11
抛出异常通常是可行的方法,您应该使用UnsupportedOperationException
Java API本身在collections framework中推荐使用:
引用: “这个接口中包含的‘破坏性’方法,也就是修改它们操作的集合的方法,如果此集合不支持该操作,则指定抛出UnsupportedOperationException。”
如果您创建完整的类层次结构,则可以选择另一种方法,即创建一个超类Vector,该类不可修改,并且没有像set()这样的修改方法,以及两个子类ImmutableVector(保证不可变)和MutableVector(可修改)。这将更好,因为您可以获得编译时安全性(您甚至无法尝试修改ImmutableVector),但根据情况可能会过于工程化(并且您可能最终会得到许多类)。
这种方法例如被选择用于Scala集合,它们都存在三种变体。
在所有需要修改向量的情况下,您使用类型MutableVector。在所有不关心修改而只想从中读取的情况下,您只需使用Vector。在所有希望保证不可变性的情况下(例如,将Vector实例传递到构造函数中并希望确保以后没有人会修改它),您使用ImmutableVector(通常会提供一个复制方法,因此您只需调用ImmutableVector.copyOf(vector))。从Vector进行向下转换通常是不必要的,应该指定所需的正确子类型作为类型。这种解决方案的整个重点(需要更多代码)是类型安全和编译时检查,因此向下转换会破坏这种好处。但也有例外,例如ImmutableVector.copyOf()方法通常会像这样进行优化:
public static ImmutableVector copyOf(Vector v) {
  if (v instanceof ImmutableVector) {
    return (ImmutableVector)v;
  } else {
    return new ImmutableVector(v);
  }
}

这仍然是安全的,并给你提供了一个不可变类型的额外好处,即避免不必要的复制。 Guava库有很多不可变集合实现,遵循这些模式(尽管它们仍然扩展可变的标准Java集合接口,因此不提供编译时安全性)。您应该阅读它们的描述以了解更多关于不可变类型的信息。
第三种选择是仅拥有一个不可变的向量类,而没有任何可变类。通常情况下,为了性能而使用可变类是一种过早的优化,因为Java非常擅长处理许多小型短期临时对象(由于有代际垃圾收集器,对象分配非常便宜,最好的情况甚至可以没有任何成本的对象清理)。当然,这对于像列表这样的非常大的对象来说并不是解决方案,但对于三维向量来说,这肯定是一个选项(也可能是最好的组合,因为它简单、安全,并且需要比其他方法更少的代码)。我不知道Java API中有没有这种选择的例子,因为在创建API的大部分内容的那些日子里,VM还没有被优化,所以太多的对象可能是一个性能问题。但这不应该阻止你今天这样做。

超类上是否仍然有一个 set() 方法?如果基类或接口没有 set() 方法,我将不得不向上转型为 MutableVector 才能访问 setter,这也意味着首先要检查 position 是否是 MutableVector 的实例。 - crush
在这种情况下,如果实例首先没有设置为不可变实例,我只想改变实例。如果是不可变实例,那么我想创建一个新的可变实例,并将其分配给position。因此,从那时起,我总是需要将其转换为MutableVector,以便于对向量进行set操作。 - crush
除非出现性能问题,否则您应该努力只拥有一个不可变类。这将带来额外的好处,避免每次进入此情况时都需要一个基本/不可变/可变层次结构。 - Daniel Kaplan
1
在这种情况下,您可以(并且需要)进行强制类型转换,尽管我真的不喜欢那种设计。 - Philipp Wendler
我想感谢你提供的所有信息和研究。我决定采用你提出的一些建议以及tieTYT上面的建议。我将为Vector3使用一个接口,但是将set()放在它上面。它将返回一个Vector3实例。该实例将在MutableVector3上是this,在ImmutableVector3上是new MutableVector3(x,y,z)。这将有助于实现我希望得到的享元模型。不过,我无法决定接受哪个答案,因为你们两个都对我的解决方案做出了同等的贡献。 - crush
显示剩余3条评论

3

根据您的情况,我认为您应该抛出一个UnsupportedOperationException异常。原因如下:

package com.sandbox;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Sandbox {

    public static void main(String[] args) throws IOException {
        List<Object> objects = Collections.unmodifiableList(new ArrayList<Object>());
        objects.add(new Object());
    }

}

这将抛出一个UnsupportedOperationException,与您的情况非常相似。

但更好的做法(可能不可能)是设计您的不可变类,以便首先没有可调用的突变方法。 作为开发人员,我不想在运行时了解到我不能使用某个方法,我希望在编译时或之前就能学习到。

您的set方法应返回一个新的ImmutableVector,它不会使任何东西突变。

您可能想采用@SotiriosDelimanolis的想法,使两个类都实现一个没有setter的接口,然后让您的代码传递接口。 再次说明,鉴于您的旧代码,这可能不太可能。


我一直在考虑SotiriosDelimanolis的建议。难道我不需要将其转换为MutableVector3才能进行变异吗?这也需要进行额外的检查:if (position instanceof MutableVector3) 我认为。 - crush
类似于 MutableVector3public Vector3 set(x, y,z) { /*...*/ return this; },以及 ImmutableVector3public Vector3 set(x, y, z) { /*...*/ return new Vector3(x, y, z);} - crush
@crush 是的。如果你让所有调用MutableVector3.set的旧代码都这样做:x.myMutableVector = x.myMutableVector.set(x, y, z);,那么你应该能够用你的ImmutableVector3替换所有实现,并完全删除你的MutableVector3。不幸的是,如果你忘记说x.myMutableVector =,编译器不会帮助你。 - Daniel Kaplan
我非常喜欢你提出的从set方法返回一个实例的建议。这简化了我的代码,也方便了享元模式!当有人尝试在不可变的特殊情况下进行设置时,他们将获得一个可变的Vector3,其中包含他们设置的值,然后可以从那时起使用。这个方法,再加上接口/可变/不可变对象,是我决定采取的路线!但我很难决定要接受谁的答案,因为你们两个对我的解决方案都有同等的帮助。 - crush
@crush,那不是我建议的路线,但如果这对你来说是最好的选择,那么它可能是可行的。我认为ImmutableVector上的set应该返回另一个ImmutableVector - Daniel Kaplan
显示剩余2条评论

3

如果您尝试更改不可变类,则应抛出编译器错误。

不可变类不应该扩展可变类。


1
你的意思是:从可变类中提取一个接口,然后让不可变类实现该接口? - Sotirios Delimanolis
我对于扩展一个可变类并尝试将其变为不可变类并不太确定。您对于我如何处理上述情况有任何进一步的建议吗? - crush
我更希望Java支持const方法,这将在很大程度上解决这个无聊的问题,但现在为时已晚。 :-) - C. K. Young

1
不可变类不应该派生自具体的可变类,反之亦然。相反,您应该有一个 ReadableFoo 抽象类,其中包含具体的 MutableFooImmutableFoo 派生类。这样,想要可以改变对象的方法可以要求使用 MutableFoo,想要将当前状态保存为引用的方法可以要求使用 ImmutableFoo,而不想更改对象状态并且不关心返回后状态的方法可以自由接受 ReadableFoo,无需担心它们是可变还是不可变。
最棘手的问题是决定是否最好在基类中包含检查其是否可变的方法以及会抛出异常的“set”方法,或者只是省略“set”方法。我倾向于省略“set”方法,除非该类型包含便于复制写入模式的特性。
这种模式可以通过使基类包含抽象方法AsImmutableAsMutable以及具体的AsNewMutable来实现。一个方法如果要获取给定名为GeorgeReadableFoo的当前状态,可以将其存储到字段GeorgeField中,其中GeorgeField = George.AsImmutable()GeorgeField = George.AsNewMutable(),具体取决于它是否需要修改它。如果确实需要修改它,可以设置GeorgeField = GeorgeField.AsMutable()。如果需要共享它,则可以返回GeorgeField.AsImmutable();如果希望在再次更改之前多次共享它,则可以设置GeorgeField = GeorgeField.AsImmutable()。如果许多对象需要共享状态...Foo,并且大多数对象根本不需要修改它们,但有些对象需要频繁修改它们,这种方法可能比仅使用可变或不可变对象更有效。

考虑一下我上面的例子。我有一个名为MyObject的对象,它有一个成员变量positionposition是一个Vector3类型的变量。假设Vector3是一个基类/接口,我还有两个派生类MutableVector3ImmutableVector3position最初被设置为一个ImmutableVector3的实例。直到position需要改变时,它仍然是一个ImmutableVector3。在那时,将创建一个新的MutableVector3实例来表示改变后的位置,并从此以后使用它。然而,如果基类/接口中省略了set()方法,那么每次想要进行赋值操作时都需要检查是否可变。 - crush
@crush:如果基本接口中省略了set方法,为了保持清晰,可能需要包括一个AsSameMutable()[要么将self作为可变返回,要么抛出异常];如果想在GeorgeField上调用SetXXSetYY,则需要GeorgeField=GeorgeField.AsMutable(); GeorgeField.AsSameMutable.SetXX(); GeorgeField.AsSameMutable.SetYY();。如果使用AsMutable而不是AsSameMutable,并错误地省略了第一个AsMutable,认为它已经发生了,尝试设置XX和YY将会悄无声息地失败。 - supercat

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