
浮点数比较的挑战与delta参数
在软件开发中,尤其是在涉及科学计算、金融或自定义数值类型(如自定义浮点数)时,对浮点数进行精确测试是一个常见的挑战。由于浮点数在计算机内部的表示方式,许多看似简单的算术运算都可能引入微小的精度误差。例如,0.1 + 0.2在某些情况下可能不严格等于0.3。
JUnit框架为解决这一问题提供了assertEquals(String message, Double expected, Double actual, Double delta)方法。其中,delta参数至关重要,它定义了expected和actual之间允许的最大差值。只有当|expected - actual|
初始delta设置的误区
在对自定义浮点数类型进行随机值测试时,开发者可能尝试根据输入值动态设置delta。一个常见的误区是使用Math.min(doubles[i], doubles[j])作为delta值,如下面的代码片段所示:
assertEquals("Failed " + doubles[i] + " + " + doubles[j],
doubles[i] + doubles[j],
ownFloats[i].add(ownFloats[j]).toDouble(),
Math.min(doubles[i], doubles[j]));这种设置方式存在两个主要问题:
- delta必须是非负值: assertEquals方法的delta参数预期是一个非负值。如果doubles[i]或doubles[j]中存在负数,Math.min(doubles[i], doubles[j])的结果将是负数,这违反了delta的约定,可能导致意外的行为或测试失败。
- delta的代表性不足: 即使输入值都为正,Math.min(doubles[i], doubles[j])可能过小,无法涵盖浮点运算固有的精度误差。例如,当两个较大数相加或相减时,结果的绝对误差可能远大于其中较小输入值的绝对值。这会导致即使计算结果在可接受的误差范围内,测试也可能失败。
考虑以下错误示例:
java.lang.AssertionError: Failed -0.01393084463838419 + -0.01393084463838419 Expected :-0.02786168927676838 Actual :-0.027861595153808594
这里,expected和actual之间存在一个微小差异。如果delta被错误地设置为负值或一个不合适的正值,就会导致断言失败。
动态delta的正确设置方法
为了克服上述问题,一个更稳健的动态delta设置策略是基于操作数的绝对值和相对误差来确定。推荐的方法是使用Math.max(Math.abs(doubles[i]), Math.abs(doubles[j])) / 100.0。
// 计算预期结果
double expectedAdd = doubles[i] + doubles[j];
// 获取实际结果
double actualAdd = ownFloats[i].add(ownFloats[j]).toDouble();
// 计算动态delta
double deltaAdd = Math.max(Math.abs(doubles[i]), Math.abs(doubles[j])) / 100.0;
assertEquals("Failed " + doubles[i] + " + " + doubles[j],
expectedAdd, actualAdd, deltaAdd);
// 减法同理
double expectedSub = doubles[i] - doubles[j];
double actualSub = ownFloats[i].sub(ownFloats[j]).toDouble();
double deltaSub = Math.max(Math.abs(doubles[i]), Math.abs(doubles[j])) / 100.0; // 或者根据预期结果的magnitude来调整
assertEquals("Failed " + doubles[i] + " - " + doubles[j],
expectedSub, actualSub, deltaSub);这种delta设置方法的优势在于:
- 始终为正: Math.abs()确保了delta始终是非负的。
- 基于相对误差: Math.max(Math.abs(doubles[i]), Math.abs(doubles[j]))取两个操作数中绝对值较大的一个,这通常能更好地代表参与运算的数值的量级。通过将其除以一个常数(如100.0),我们实际上设置了一个相对误差阈值(例如,1%)。这意味着对于较大的数,delta也会相应变大,允许更大的绝对误差;对于较小的数,delta也会变小,保持相对精度。这对于测试跨度较大的浮点数范围非常有效。
- 动态适应: delta不再是固定值,而是根据当前测试用例的输入动态调整,使得测试更加灵活和鲁棒。
完整的测试方法示例
结合上述改进,原始的测试方法可以更新为:
import org.junit.jupiter.api.Test; // 假设使用JUnit 5
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.DoubleStream;
// 假设有一个OwnFloat类,实现了add和sub方法
class OwnFloat {
private double value;
public OwnFloat(double value) {
this.value = value;
}
public OwnFloat add(OwnFloat other) {
// 实际的自定义浮点数加法逻辑,可能引入精度误差
return new OwnFloat(this.value + other.value);
}
public OwnFloat sub(OwnFloat other) {
// 实际的自定义浮点数减法逻辑,可能引入精度误差
return new OwnFloat(this.value - other.value);
}
public double toDouble() {
return this.value;
}
}
public class OwnFloatTest {
@Test
public void testRandomMath() {
DoubleStream doubleStream = ThreadLocalRandom.current().doubles(100);
// 限制double的范围,使其在OwnFloat类可处理的范围内
double[] doubles = doubleStream.map(d -> {
if (ThreadLocalRandom.current().nextBoolean()) {
return d * -1d;
} else {
return d;
}
}).map(d -> d * Math.pow(2, ThreadLocalRandom.current().nextInt(-8, 9))).toArray();
OwnFloat[] ownFloats = new OwnFloat[doubles.length];
for (int i = 0; i < doubles.length; i++) {
ownFloats[i] = new OwnFloat(doubles[i]);
}
for (int i = 0; i < doubles.length; i++) {
for (int j = 0; j < doubles.length; j++) {
// 加法测试
double expectedAdd = doubles[i] + doubles[j];
double actualAdd = ownFloats[i].add(ownFloats[j]).toDouble();
// 使用动态delta,基于操作数中绝对值较大的一个
// 100.0可以根据实际精度要求调整,例如1000.0或更高
double deltaAdd = Math.max(Math.abs(doubles[i]), Math.abs(doubles[j])) / 100.0;
// 如果deltaAdd过小(例如接近0),可以设置一个最小delta值,防止除以零或delta过小
if (deltaAdd < Double.MIN_NORMAL) { // Double.MIN_NORMAL是最小正double值
deltaAdd = Double.MIN_NORMAL;
}
assertEquals("Failed " + doubles[i] + " + " + doubles[j],
expectedAdd, actualAdd, deltaAdd);
// 减法测试
double expectedSub = doubles[i] - doubles[j];
double actualSub = ownFloats[i].sub(ownFloats[j]).toDouble();
double deltaSub = Math.max(Math.abs(doubles[i]), Math.abs(doubles[j])) / 100.0;
if (deltaSub < Double.MIN_NORMAL) {
deltaSub = Double.MIN_NORMAL;
}
assertEquals("Failed " + doubles[i] + " - " + doubles[j],
expectedSub, actualSub, deltaSub);
}
}
}
}注意事项与最佳实践
- delta必须为正: 这是最基本的原则,无论采用何种计算方式,最终的delta值都必须大于等于零。
-
delta的选取:相对误差 vs. 绝对误差:
- 绝对误差: 当被测试的浮点数范围很小且已知时,可以使用一个固定的、非常小的正数作为delta(例如1e-9)。
- 相对误差: 当被测试的浮点数范围很广时(从非常小到非常大),基于相对误差的delta(如Math.max(Math.abs(a), Math.abs(b)) / N)更为合适。N的值需要根据你的精度要求和浮点数实现来调整。
- 基于预期结果的delta: 在某些情况下,delta可能更适合基于expected值来计算,例如Math.abs(expected) * relativeErrorTolerance。这可以更好地反映结果本身的精度要求。
- ULP (Units in the Last Place): 对于需要极高精度的浮点数比较,可以使用ULP(末位单位)作为误差度量。JUnit 5的Assertions.assertEquals(expected, actual, someUlps)提供了这种功能。ULP表示一个浮点数与下一个可表示的浮点数之间的最小距离,它能更精确地反映浮点数的精度限制。
- 避免零点附近的delta问题: 当expected或actual非常接近零时,基于相对误差的delta可能会变得非常小,甚至为零。此时,最好设置一个最小的delta阈值(例如Double.MIN_NORMAL或一个小的固定值),以避免除以零或delta过小导致不必要的失败。
- 累积误差: 复杂的浮点运算会累积误差。如果你的自定义浮点数实现涉及多步运算,那么最终结果的误差可能会比单步运算大。因此,对于复杂操作,可能需要设置更大的delta。
总结
正确处理JUnit中的浮点数断言assertEquals的delta参数是确保浮点数代码质量的关键。避免使用可能产生负值的delta,并优先考虑基于操作数或预期结果的相对误差来动态计算delta。通过采纳Math.max(Math.abs(a), Math.abs(b)) / N这种策略,并结合对delta选取原则的理解,可以构建出更健壮、更适应实际浮点数运算特性的单元测试。在追求更高精度时,ULP比较也是一个值得探索的高级选项。










