Implementing a RandomNumberGenerator for testing

Back in the Swift 4 days, I implemented a RandomNumberGenerator for the purpose of testing, which is to say, it always returns the same sequence of UInt64's, so that I can validate code that accepts a RandomNumberGenerator. However, as of "circa" Swift 5, this test is reliably failing, because even though it returns a different number in the next function, it fails to return any element except the first one from arrays (sequences, etc.).

Sample code is here:

class MockRandomNumberGenerator: RandomNumberGenerator {

    var current: UInt64 = 0

    func next() -> UInt64 {
        defer { current += 1 }
        return current
    }
}

var mockRandomNumberGenerator = MockRandomNumberGenerator()

let testArray = 0..<10

for _ in 0..<testArray.count {
    print("testArray.randomElement   : \(testArray.randomElement(using: &mockRandomNumberGenerator)!)")

    print("testArray[Int.random(...)]: \(testArray[Int.random(in: 0..<testArray.count, using: &mockRandomNumberGenerator)])")
}

The output of this is always 0, for every iteration. I expected the sequence to be returned in order, from 0 to 9. So, it fails in the same way for both array randomElement() and range random(in:). Prior to Swift 5, this same implementation returned the sequence in order.

What do I need to do in the random number generator implementation to get it to work with arrays (and ranges) correctly?

Replies

Seems the value generated by next() needs to be large enough in the current implementation of random(in:using:).

(randomElement(using:) may very probably be using the similar method internally.)

class MockRandomNumberGenerator: RandomNumberGenerator {

    var current: UInt64 = 0

    func next() -> UInt64 {
        defer { current += 1 }
        return current &* (UInt64.max/10)
    }
}

The header doc says Each call to next() must produce a uniform and independent random value. You may need to generate seemingly uniform values to make future algorithms of random(in:using:) work as you expect.

  • I'm probably missing something important, here, so bear with me. The suggestion works for this specific use case, but what role does the number 10 play? If it's tied to my use case's particular array size, then this solution won't scale for use with other arrays. For example, if I make the array size 20, I'll never get even values from this algorithm. This leads me to believe that what I need to do is override the template method that accepts the max value, perhaps like this:

        func next<T>(upperBound: T) -> T where T : FixedWidthInteger, T : UnsignedInteger {         defer { current += 1 }         return T(current) &* (T.max/upperBound)     }

    However, that does not appear to get called when randomElement() or random(in:) is called.

  • Please re-read again. You may need to generate seemingly uniform values.

  • That revised next() function does generate a sequence of values spaced uniformly across the range of possible Int64 values, and this happens to make the current version of randomElement() behave as desired in this case, but it’s still not future-proof.

Add a Comment

I expected the sequence to be returned in order, from 0 to 9.

This expectation is based only on the previous observed behavior, not on the documented behavior, so it turned out not to be a safe expectation.

The issue is that there is no documented relationship between (1) the value returned from your RNG's next() method; and (2) the result of calling Array.randomElement(using:) or Int.random(in:using:). That is, exactly how those methods "use" your RNG is undocumented, and is therefore subject to change. Sounds like randomElement() formerly used your RNG's result directly as the index for the random element, but now it does something different. Both those behaviors are valid as they fulfill the documented API contract.

(The documentation for randomElement() actually includes a note that emphasizes this undocumented-ness.)

What do I need to do in the random number generator implementation to get it to work with arrays (and ranges) correctly?

The current behavior is "correct", so let's change the question to:

What do I need to do in the RNG implementation to achieve specific desired behavior in Array.randomElement(using:) both now and in future versions of the Swift library?

The answer is: you can't.

However, you may be able to achieve your testing goals by using your RNG in a different way, such as using it to generate pseudo-random array indexes directly, to replace usage of the system's randomElement() method. Or maybe you just want to iterate directly over the collection and perform testing logic on each element. It just depends on what you're trying to do.