-
-
Notifications
You must be signed in to change notification settings - Fork 799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Invocations collection improvements #628
Changes from all commits
25895f1
11f1027
5738a90
8dc2ce3
61cdb4d
0dbb1bb
c9dd227
ef18220
108688d
aebd6db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
using System; | ||
using Xunit; | ||
|
||
namespace Moq.Tests | ||
{ | ||
public class InvocationsFixture | ||
{ | ||
[Fact] | ||
public void MockInvocationsAreRecorded() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
mock.Object.CompareTo(new object()); | ||
|
||
Assert.Equal(1, mock.Invocations.Count); | ||
} | ||
|
||
[Fact] | ||
public void MockInvocationsIncludeInvokedMethod() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
mock.Object.CompareTo(new object()); | ||
|
||
var invocation = mock.Invocations[0]; | ||
|
||
var expectedMethod = typeof(IComparable).GetMethod(nameof(mock.Object.CompareTo)); | ||
|
||
Assert.Equal(expectedMethod, invocation.Method); | ||
} | ||
|
||
[Fact] | ||
public void MockInvocationsIncludeArguments() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
var obj = new object(); | ||
|
||
mock.Object.CompareTo(obj); | ||
|
||
var invocation = mock.Invocations[0]; | ||
|
||
var expectedArguments = new[] {obj}; | ||
|
||
Assert.Equal(expectedArguments, invocation.Arguments); | ||
} | ||
|
||
[Fact] | ||
public void MockInvocationsCanBeEnumerated() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
mock.Object.CompareTo(-1); | ||
mock.Object.CompareTo(0); | ||
mock.Object.CompareTo(1); | ||
|
||
var count = 0; | ||
|
||
using (var enumerator = mock.Invocations.GetEnumerator()) | ||
{ | ||
while (enumerator.MoveNext()) | ||
{ | ||
Assert.NotNull(enumerator.Current); | ||
|
||
count++; | ||
} | ||
} | ||
|
||
Assert.Equal(3, count); | ||
} | ||
|
||
[Fact] | ||
public void MockInvocationsCanBeCleared() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
mock.Object.CompareTo(new object()); | ||
|
||
mock.ResetCalls(); | ||
|
||
Assert.Equal(0, mock.Invocations.Count); | ||
} | ||
|
||
[Fact] | ||
public void MockInvocationsCanBeRetrievedByIndex() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
mock.Object.CompareTo(-1); | ||
mock.Object.CompareTo(0); | ||
mock.Object.CompareTo(1); | ||
|
||
var invocation = mock.Invocations[1]; | ||
|
||
var arg = invocation.Arguments[0]; | ||
|
||
Assert.Equal(0, arg); | ||
} | ||
|
||
[Fact] | ||
public void MockInvocationsIndexerThrowsIndexOutOfRangeWhenCollectionIsEmpty() | ||
{ | ||
var mock = new Mock<IComparable>(); | ||
|
||
Assert.Throws<IndexOutOfRangeException>(() => mock.Invocations[0]); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -47,20 +47,20 @@ namespace Moq | |
{ | ||
internal sealed class InvocationCollection : IReadOnlyList<IReadOnlyInvocation> | ||
{ | ||
private List<Invocation> invocations; | ||
private Invocation[] invocations; | ||
|
||
public InvocationCollection() | ||
{ | ||
this.invocations = new List<Invocation>(); | ||
} | ||
private int capacity = 0; | ||
private int count = 0; | ||
|
||
private readonly object invocationsLock = new object(); | ||
|
||
public int Count | ||
{ | ||
get | ||
{ | ||
lock (this.invocations) | ||
lock (this.invocationsLock) | ||
{ | ||
return this.invocations.Count; | ||
return count; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this (In fact, we could question the whole point of having a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not there to avoid a torn read, but to protect against reordering optimisations and variable caching. If you want to go down that avenue, it might require marking the backing field as volatile, or otherwise setting up a memory barrier. I don't advise pulling on this thread unless you think it's very important. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't know reordering happened across different methods. But yeah, this isn't super-important. Let's leave it as is, then. |
||
} | ||
} | ||
|
@@ -69,55 +69,101 @@ public int Count | |
{ | ||
get | ||
{ | ||
lock (this.invocations) | ||
lock (this.invocationsLock) | ||
{ | ||
if (this.count <= index || index < 0) | ||
{ | ||
throw new IndexOutOfRangeException(); | ||
} | ||
|
||
return this.invocations[index]; | ||
} | ||
} | ||
} | ||
|
||
public void Add(Invocation invocation) | ||
{ | ||
lock (this.invocations) | ||
lock (this.invocationsLock) | ||
{ | ||
this.invocations.Add(invocation); | ||
} | ||
} | ||
if (this.count == this.capacity) | ||
{ | ||
var targetCapacity = this.capacity == 0 ? 4 : (this.capacity * 2); | ||
Array.Resize(ref this.invocations, targetCapacity); | ||
this.capacity = targetCapacity; | ||
} | ||
|
||
public bool Any() | ||
{ | ||
return this.invocations.Count > 0; | ||
this.invocations[this.count] = invocation; | ||
this.count++; | ||
} | ||
} | ||
|
||
public void Clear() | ||
{ | ||
lock (this.invocations) | ||
lock (this.invocationsLock) | ||
{ | ||
this.invocations.Clear(); | ||
// Replace the collection so readers with a reference to the old collection aren't interrupted | ||
this.invocations = null; | ||
this.count = 0; | ||
this.capacity = 0; | ||
} | ||
} | ||
|
||
public Invocation[] ToArray() | ||
{ | ||
lock (this.invocations) | ||
lock (this.invocationsLock) | ||
{ | ||
return this.invocations.ToArray(); | ||
if (this.count == 0) | ||
{ | ||
return new Invocation[0]; | ||
} | ||
|
||
var result = new Invocation[this.count]; | ||
|
||
Array.Copy(this.invocations, result, this.count); | ||
|
||
return result; | ||
} | ||
} | ||
|
||
public Invocation[] ToArray(Func<Invocation, bool> predicate) | ||
{ | ||
lock (this.invocations) | ||
lock (this.invocationsLock) | ||
{ | ||
return this.invocations.Where(predicate).ToArray(); | ||
if (this.count == 0) | ||
{ | ||
return new Invocation[0]; | ||
} | ||
|
||
var result = new List<Invocation>(this.count); | ||
|
||
for (var i = 0; i < this.count; i++) | ||
{ | ||
var invocation = this.invocations[i]; | ||
if (predicate(invocation)) | ||
{ | ||
result.Add(invocation); | ||
} | ||
} | ||
|
||
return result.ToArray(); | ||
} | ||
} | ||
|
||
public IEnumerator<IReadOnlyInvocation> GetEnumerator() | ||
{ | ||
lock (this.invocations) | ||
// Take local copies of collection and count so they are isolated from changes by other threads. | ||
Invocation[] collection; | ||
int count; | ||
|
||
lock (this.invocationsLock) | ||
{ | ||
collection = this.invocations; | ||
count = this.count; | ||
} | ||
|
||
for (var i = 0; i < count; i++) | ||
{ | ||
return this.invocations.ToList().GetEnumerator(); | ||
yield return collection[i]; | ||
} | ||
} | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose I might be missing something obvious here, but I'd really like to avoid us reinventing the wheel and programming another version of
List<>
. Why is this necessary?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we continue to use
List<T>
you also have to useList<T>.Enumerator
which isn't going to behave the way we want in concurrent-execution scenarios. Arrays, being primitives, have a much simpler access model, so we can know and take advantage of what operations are safe to do concurrently and which are not.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't we only end up with
List<T>.Enumerator
if we used LINQ or aforeach
loop? What if we went for a simplefor
loop that accesses the list by index in the range0..n
wheren
is the list'sCount
at the moment when enumeration starts?Count
can only increase. If aClear
is performed, that won't have to affect already-started enumerations since they continue seeing an old, detached list.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did consider it, but it has the same problem. When you call
List<T>.Add(T)
, it's not concurrency-safe with its indexer. A path exists where you wind up in an unknown state, like if the underlying array has to be resized. We could try to work out how the implementation ofList<T>
works and try to handle these edge-cases, but I think it's considerably easier to just use an array. The only method with any real complexity is ourAdd(Invocation)
and most of that is delegated to the fantasticArray.Resize
method.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't see the problem yet. Due to the locks in
InvocationCollection
, only one thread can ever access the list at a time. If anAdd
causes a resize, then that resize happens inside thelock
and noone else can read the list. The indexer read access also happens inside a lock, so while that happens, noone can modify the list.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry if the following might be a little naïve—I'm not sure right now how the C# compiler transforms
lock
blocks whenyield return
gets involved, a singlelock
around the whole method body might actually suffice:There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A
lock
around thefor
loop would actually make it so the enumerator would acquire the collection until all elements had been iterated over. What you have should acquire a lock to the collection every time it yields an item, then release the lock.I do like how much less code there is here. I also like that my implementation only acquires a lock once, versus once-per-item + 1 times. I could go either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps there is a middle way. I feel it should be possible to let the compiler generate the enumerator class, which would help reduce the amount of additional code. I'll look into this more closely a little later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added an implementation using
yield return
. It should be functionally identical to the original, except it doesn't supportIEnumerator.Reset()
.