0.1.0 |
|
---|
#86 in #delta
55KB
840 行
Delta:Rust中的结构化差异比较
delta
crate定义了一个trait Delta
,以及一个用于自动生成该trait实例的derive宏,适用于大多数数据类型。该trait的主要目的是提供一个方法delta
,通过这个方法,任何支持此trait的类型值都可以得到它们之间的差异摘要。
注意,与其它执行数据差异比较的crate(主要是在标量和集合之间)不同,delta
主要是为了测试而编写的。也就是说,生成此类变化描述的目的是为了能够编写测试,以断言在初始状态和最终状态之间某些操作后的预期变化集。这也意味着一些类型,如HashMap
,必须在排序键之后进行比较,以便生成的变化集是确定性的,从而可以表达为测试期望。
为此,还提供了函数delta::assert_changes
,它接受两个相同类型的值以及由foo.delta(&bar)
返回的预期“变化描述”。此函数在内部使用pretty_assertions
crate,以便在失败的输出中轻松看到深层结构中的微小差异。
快速入门
如果您想快速使用delta
crate来增强单元测试,请按照以下步骤操作
- 将
delta
crate作为依赖项添加,并启用features = ["derive"]
。 - 在所需的结构体和枚举类型上派生
delta::Delta
trait。 - 将单元测试结构化为三个阶段:a. 创建您打算测试的初始状态或数据集,并对其进行复制。b. 将操作和更改应用于此状态。c. 使用
delta::assert_changes
在初始状态和结果状态之间断言所发生的事情正是您所期望的。
与通常的“探测”结果状态的方法相比,这种方法的主要优点是它断言了所有可能的更改集,以确保没有发生意外的副作用。因此,它既是一种正向测试,也是一种负向测试:检查您期望看到的内容以及您不期望看到的内容。
Delta特性
Delta
特性有两个关联类型和两个方法,一对对应于值描述,另一对应于值更改
pub trait Delta {
type Desc: PartialEq + Debug;
fn describe(&self) -> Self::Desc;
type Change: PartialEq + Debug;
fn delta(&self, other: &Self) -> Changed<Self::Change>;
}
描述:关联类型Desc
由于值层次结构可能涉及许多类型,因此需要值描述(关联类型Desc
)。可能一些这些类型实现了PartialEq
和Debug
,但并非所有。为了克服这种限制,Delta
derive宏创建了一个具有所有相同构造函数和字段的“镜像”数据结构,但使用Desc
关联类型为每个包含类型。
#[derive(Delta)]
struct Foo {
bar: Bar,
baz: Baz
}
这会生成一个与原始类型相同的描述,但使用类型描述而不是类型本身
struct FooDesc {
bar: <Bar as Delta>::Desc,
baz: <Baz as Delta>::Desc
}
您还可以选择其他描述类型,例如值的简化形式或其他类型。例如,复杂的结构可以通过它们从一个Default
值代表的更改集来描述。这是如此常见,以至于它通过delta
提供的compare_default
宏属性得到了支持
#[derive(Delta)]
#[compare_default]
struct Foo { /* ...lots of fields... */ }
impl Default for Foo { /* ... */ }
对于标量,Desc
类型与它所描述的类型相同,这些被称为“自描述的”。
还提供了其他宏属性,用于进一步自定义,以下从结构部分开始介绍。
更改:关联类型Change
当两个类型的值不同时,这种差异会使用关联类型Change
表示。这些值由delta
方法产生,该方法实际上返回Changed<Change>
,因为结果可能是Changed::Unchanged
或Changed::Changed(_changes_)
。
注[^option] Changed
只是Option
类型的一种不同风味,创建出来是为了使更改集比在各个地方看到Some
更清晰。
Change
值的主要目的是将其与您期望看到的更改集进行比较,因此已经做出了设计选择,以优化清晰度和打印,而不是,比如说,通过应用更改集将一个值转换为另一个值的能力。这是可能的,给出数据集和更改描述,但没有为此目标进行任何工作。
标量、集合、结构和枚举之间的更改表示可能差异很大,因此以下部分将详细介绍每种类型。
标量
Delta
特性已为所有基本标量类型实现。这些是自我描述的,并使用以类型命名的 Change
结构,该结构包含前一个值和更改后的值。例如,以下断言成立
assert_changes(&100, &100, Changed::Unchanged);
assert_changes(&100, &200, Changed::Changed(I32Change(100, 200)));
assert_changes(&true, &false, Changed::Changed(BoolChange(true, false)));
assert_changes(
&"foo",
&"bar",
Changed::Changed(StringChange("foo".to_string(), "bar".to_string())),
);
Vec 和 Set 集合
已为以下集合实现了 Delta
: Vec
、HashSet
和 BTreeSet
。
Vec
使用 Vec<VecChange>
来报告所有发生更改的索引。请注意,它无法检测中间的插入,因此可能从那里报告每个元素都发生了更改,直到向量的末尾,此时它将报告一个新添加的成员。
HashSet
和 BTreeSet
类型都使用 SetChange
类型以相同的方式报告更改。请注意,为了使 HashSet
的更改结果具有确定性,HashSet
中的值必须支持 Ord
特性,以便在比较之前进行排序。集合无法知道特定成员何时更改,因此只能报告 SetChange::Added
和 SetChange::Removed
中的更改。
以下是一些示例,取自 delta_test
测试套件
// Vectors
assert_changes(
&vec![1 as i32, 2],
&vec![1 as i32, 2, 3],
Changed::Changed(vec![VecChange::Added(2, 3)]),
);
assert_changes(
&vec![1 as i32, 3],
&vec![1 as i32, 2, 3],
Changed::Changed(vec![
VecChange::Changed(1, I32Change(3, 2)),
VecChange::Added(2, 3),
]),
);
assert_changes(
&vec![1 as i32, 2, 3],
&vec![1 as i32, 3],
Changed::Changed(vec![
VecChange::Changed(1, I32Change(2, 3)),
VecChange::Removed(2, 3),
]),
);
assert_changes(
&vec![1 as i32, 2, 3],
&vec![1 as i32, 4, 3],
Changed::Changed(vec![VecChange::Changed(1, I32Change(2, 4))]),
);
// Sets
assert_changes(
&HashSet::from(vec![1 as i32, 2].into_iter().collect()),
&HashSet::from(vec![1 as i32, 2, 3].into_iter().collect()),
Changed::Changed(vec![SetChange::Added(3)]),
);
assert_changes(
&HashSet::from(vec![1 as i32, 3].into_iter().collect()),
&HashSet::from(vec![1 as i32, 2, 3].into_iter().collect()),
Changed::Changed(vec![SetChange::Added(2)]),
);
assert_changes(
&HashSet::from(vec![1 as i32, 2, 3].into_iter().collect()),
&HashSet::from(vec![1 as i32, 3].into_iter().collect()),
Changed::Changed(vec![SetChange::Removed(2)]),
);
assert_changes(
&HashSet::from(vec![1 as i32, 2, 3].into_iter().collect()),
&HashSet::from(vec![1 as i32, 4, 3].into_iter().collect()),
Changed::Changed(vec![SetChange::Added(4), SetChange::Removed(2)]),
);
注意,如果上面的第一个 VecChange::Change
使用了索引 1 而不是 0,则导致的失败将类似于以下内容
running 1 test
test test_delta_bar ... FAILED
failures:
---- test_delta_bar stdout ----
thread 'test_delta_bar' panicked at 'assertion failed: `(left == right)`
Diff < left / right > :
Changed(
[
Change(
< 1,
> 0,
I32Change(
100,
200,
),
),
],
)
', /Users/johnw/src/delta/delta/src/lib.rs:19:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
test_delta_bar
Map 集合
jww (2021-11-01):待办事项
结构
对任意结构进行差异比较是创建 delta
的原始动机。这可以通过使用 Delta
derive 宏来实现,该宏自动生成所需比较的代码。本节的目的在于解释该宏的工作原理以及可以用于指导过程的各个属性宏。如果所有其他方法都失败了,手动特性行为始终是一个替代方案。
对于以下子节的内容,我们考虑以下结构
#[derive(Delta)]
struct Foo {
bar: Bar,
baz: Baz,
#[delta_ignore]
baz: Box<dyn FnOnce(u32)>
}
您将注意的第一个可以应用于单个字段的属性宏是 #[delta_ignore]
,如果问题中的类型无法进行比较差异,则必须使用它。
待办事项:jww (2021-11-01):允许将 Desc
和 Change
后缀都更改。
待办事项:jww (2021-11-01):为枚举中的每个多字段变体生成一个辅助 Change
结构,并将该变体的类型设置为枚举的 Change
为 Vec<Change>
。
待办事项:jww (2021-11-01):提供一个属性宏 #[delta_wrap]
,该宏定义了一个用于比较的包装类型。当在 delta
中遇到字段时,使用包装器构造一个临时值,然后在该值上调用 delta
。
待办:jww(2021-11-01):提供一个属性宏 #[delta_view(function)]
,用于定义接收 &self
作为参数并返回一个可进行差异计算的 Delta
类型的合成属性。
为结构体推导 Delta:Desc 类型
默认情况下,为结构体推导 Delta
将创建一个与该结构体相同的“镜像”,所有字段都相同,但将每个类型 T
替换为 <T as Delta>::Desc
struct FooDesc {
bar: <Bar as Delta>::Desc,
baz: <Baz as Delta>::Desc
}
此过程可以通过多个属性宏来影响。
compare_default
当使用 #[compare_default]
属性宏时,将 Desc
类型定义为与 Change
类型相同,并将 describe
方法实现为与 Default::default()
的值进行比较。
type Desc = Self::Change;
fn describe(&self) -> Self::Desc {
Foo::default().delta(self).unwrap_or_default()
}
type Change = Vec<FooChange>;
请注意,由于这允许为每个字段分别报告更改,因此结构体的更改始终是一个向量。有关此内容的更多信息,请参阅下一节。
no_description
如果您对类型不希望有任何描述,因为您只关心它的更改,并且永远不想在任何其他上下文中报告值的描述,则可以使用 #[no_description]
。这将把 Desc
类型设置为单元类型,并相应地实现 describe
方法。
type Desc = ();
fn describe(&self) -> Self::Desc {
()
}
假设在这种情况下,这样的值永远不会出现在任何更改输出中,因此如果您看到大量单元出现,请考虑不同的方法。
describe_type
和 describe_body
您可以通过指定 Desc
类型及其 describe
函数体应显示的确切文本来对描述进行更多控制。基本上,对于以下定义
#[derive(Delta)]
#[describe_type(T)]
#[describe_body(B)]
struct Foo {
bar: Bar,
baz: Baz
}
以下代码将生成
type Desc = T;
fn describe(&self) -> Self::Desc {
B
}
这也意味着传递给 describe_body
的表达式参数可以引用 self
参数。以下是一个实际用例
#[cfg_attr(feature = "delta",
derive(delta::Delta),
describe_type(String),
describe_body(self.to_string()))]
可以使用相同的方法通过校验和哈希表示大型数据块,例如,或通过 Merkle 根哈希表示不需要显示的大型数据结构。
为结构体推导 Delta:Change 类型
对于结构体,默认情况下推导 Delta
会创建一个枚举类型,该枚举类型具有 struct
中每个字段的变体,并使用此类值的向量来表示更改。这意味着对于以下定义
#[derive(Delta)]
struct Foo {
bar: Bar,
baz: Baz
}
Change
类型被定义为 Vec<FooChange>
,其中 FooChange
如下所示
#[derive(PartialEq, Debug)]
enum FooChange {
Bar(<Bar as Delta>::Change),
Baz(<Baz as Delta>::Change),
}
impl Delta for Foo {
type Desc = FooDesc;
type Change = Vec<FooChange>;
}
以下是一个简化的示例,说明如何断言更改
assert_changes(
&initial_foo, &later_foo,
Changed::Changed(vec![
FooChange::Bar(...),
FooChange::Baz(...),
]));
如果字段未更改,则它不会出现在向量中,并且每个字段最多出现一次。采用这种方法的原因是,如果大多数其他字段保持不变,则具有许多字段的结构体可以用一个小更改集表示。
特殊情况:单元结构体
如果一个结构体没有任何字段,它永远不能改变,因此只生成一个单一的 Desc
类型。
特殊情况:单例结构体
如果一个结构体只有一个字段,就没有必要使用向量来指定变更,因为结构体要么没有改变,要么只有一个字段发生了改变。因此,单例结构体优化掉向量,并在其 Delta
派生中使用 type Change = [type]Change
,而不是像多字段结构体那样使用 type Change = Vec<[type]Change>
。
delta_public
和 delta_private
默认情况下,自动生成的 Desc
和 Change
类型与其父类型的可见性相同。但是,如果您希望保留原始数据类型为私有,但允许导出描述和变更集,则这可能不合适。为了支持这一点,您可以使用 #[delta_public]
和 #[delta_private]
来明确指定这些生成类型的可见性。
枚举
枚举的处理与结构体大不相同,主要原因是虽然结构体总是字段的乘积,但枚举可以不仅是变体的总和——也可以是乘积的总和。
为了更清楚地解释这一点:通过字段的乘积,我们的意思是结构体是一组类型化的字段的简单组合,其中相同的字段对于这种结构体的每个值都是可用的。
同时,枚举是一个变体的总和或选择,但其中一些变体可以包含字段的组,就像在变体中有嵌入的无名结构体一样。考虑以下 enum
,它将被用于以下所有示例
#[derive(Delta)]
enum MyEnum {
One(bool),
Two { two: Vec<bool>, two_more: Baz },
Three,
}
在这里,我们可以看到有一个没有字段的变体(Three
),一个带有无名字段的变体(One
),以及一个带有像常规结构体一样的命名字段的变体(Two
)。然而,问题在于这些嵌入的结构体永远不会作为独立的类型表示,所以我们不能为它们定义 Delta
并计算枚举参数之间的差异。我们也不能简单地创建一个带有真实名称的字段类型的副本,并为它生成 Delta
,因为不是每个值都是可复制的或可克隆的,而且用所有具有引用类型字段的新层次结构自动生成会变得非常复杂...
因此,以下内容被生成,这可能会变得相当冗长,但可以捕捉到任何差异的完整性质
enum MyEnumChange {
BothOne(<bool as Delta>::Change),
BothTwo {
two: Changed<<Vec<bool> as Delta>::Change>,
two_more: Changed<Baz as Delta>::Change
},
BothThree,
Different(<MyEnum as Delta>::Desc, <MyEnum as Delta>::Desc),
}
请注意,具有单例字段的变体不使用 Change
,因为当变体报告为已更改时,该信息已经反映在例如 BothOne
中。在 BothTwo
的情况下,每个字段类型都被包装在 Changed
中,因为可能一个或两个字段都可能已更改。
特殊情况:空枚举
如果枚举没有变体,则无法构造,因此省略了Desc
或Change
类型,并且始终报告为未更改。
联合
目前联合不能派生Delta
实例。
依赖
~1.5MB
~35K SLoC