From 65e90ea0119e0c9144872e19f77890a7de9ee962 Mon Sep 17 00:00:00 2001 From: Matt Zanchelli Date: Wed, 23 Dec 2020 18:54:38 -0500 Subject: [PATCH] Add `contains(countIn:where:)` and related functions to `Sequence` --- Guides/ContainsCountWhere.md | 87 ++++++ Sources/Algorithms/ContainsCountWhere.swift | 251 ++++++++++++++++++ .../ContainsCountWhereTests.swift | 160 +++++++++++ 3 files changed, 498 insertions(+) create mode 100644 Guides/ContainsCountWhere.md create mode 100644 Sources/Algorithms/ContainsCountWhere.swift create mode 100644 Tests/SwiftAlgorithmsTests/ContainsCountWhereTests.swift diff --git a/Guides/ContainsCountWhere.md b/Guides/ContainsCountWhere.md new file mode 100644 index 00000000..4498677f --- /dev/null +++ b/Guides/ContainsCountWhere.md @@ -0,0 +1,87 @@ +# Contains Count Where + +[[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/ContainsCountWhere.swift) | + [Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/ContainsCountWhereTests.swift)] + +Returns whether or not a sequence has a particular number of elements matching a given criteria. + +If you need to compare the count of a filtered sequence, using this method can give you a performance boost over filtering the entire collection, then comparing its count. + +```swift +let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +print(numbers.contains(atLeast: 2, where: { $0.isMultiple(of: 3) })) +// prints "true" +``` + +These functions can return for _some_ infinite sequences with _some_ predicates whereas `filter(_:)` followed by `count` can’t ever do that, resulting in an infinite loop. For example, finding if there are more than 500 prime numbers with four digits (base 10). Note that there are 1,061 prime numbers with four digits, significantly more than 500. + +```swift +// NOTE: Replace `primes` with a real infinite prime number `Sequence`. +let primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, … +print(primes.contains(moreThan: 500, where: { String($0).count == 4 })) +// prints "true" +``` + +## Detailed Design + +A function named `contains(countIn:where:)` added as an extension to `Sequence`: + +```swift +extension Sequence { + public func contains( + countIn rangeExpression: R, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool where R.Bound: FixedWidthInteger +} +``` + +Five small wrapper functions added to make working with different ranges easier and more readable at the call-site: + +```swift +extension Sequence { + public func contains( + exactly exactCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool + + public func contains( + atLeast minimumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool + + public func contains( + moreThan minimumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool + + public func contains( + lessThan maximumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool + + public func contains( + lessThanOrEqualTo maximumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool +} +``` + +### Complexity + +These methods are all O(_n_) in the worst case, but often return much earlier than that. + +### Naming + +The naming of this function is based off of the `contains(where:)` function on `Sequence` in the standard library. While the standard library function only checks for a non-zero count, these functions can check for any count. + +### Comparison with other languages + +Many languages have functions like Swift’s [`count(where:)`](https://github.com/apple/swift/pull/16099) function.[1](#footnote1) While these functions are useful when needing a complete count, they do not return early when simply needing to do a comparison on the count. + +**C++:** The `` library’s [`count_if`](https://www.cplusplus.com/reference/algorithm/count_if/) + +**Ruby:** [`count{|item|block}`](https://ruby-doc.org/core-1.9.3/Array.html#method-i-count) + +---- + +1: [Temporarily removed](https://github.com/apple/swift/pull/22289#issue-249472009) \ No newline at end of file diff --git a/Sources/Algorithms/ContainsCountWhere.swift b/Sources/Algorithms/ContainsCountWhere.swift new file mode 100644 index 00000000..8f2c5a8d --- /dev/null +++ b/Sources/Algorithms/ContainsCountWhere.swift @@ -0,0 +1,251 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Algorithms open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +//===----------------------------------------------------------------------===// +// contains(countIn:where:) +//===----------------------------------------------------------------------===// + +extension Sequence { + /// Returns whether or not the number of elements of the sequence that satisfy + /// the given predicate fall within a given range. + /// + /// The following example determines if there are multiple (at least two) + /// types of animals with “lion” in its name: + /// + /// let animals = [ + /// "mountain lion", + /// "lion", + /// "snow leopard", + /// "leopard", + /// "tiger", + /// "panther", + /// "jaguar" + /// ] + /// print(animals.contains(countIn: 2..., where: { $0.contains("lion") })) + /// // prints "true" + /// + /// - Parameters: + /// - rangeExpression: The range of acceptable counts + /// - predicate: A closure that takes an element as its argument and returns + /// a Boolean value indicating whether the element should be included in the + /// count. + /// - Returns: Whether or not the number of elements in the sequence that + /// satisfy the given predicate is within a given range + /// - Complexity: Worst case O(*n*), where *n* is the number of elements. + @inlinable + public func contains( + countIn rangeExpression: R, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool where R.Bound: FixedWidthInteger { + let range = rangeExpression.relative(to: R.Bound.zero..= threshold { + return thresholdReturn + } + } + } + + return range.contains(count) + } +} + +//===----------------------------------------------------------------------===// +// contains(exactly:where:) +// contains(atLeast:where:) +// contains(moreThan:where:) +// contains(lessThan:where:) +// contains(lessThanOrEqualTo:where:) +//===----------------------------------------------------------------------===// + +extension Sequence { + /// Returns whether or not an exact number of elements of the sequence satisfy + /// the given predicate. + /// + /// The following example determines if there are exactly two bears: + /// + /// let animals = [ + /// "bear", + /// "fox", + /// "bear", + /// "squirrel", + /// "bear", + /// "moose", + /// "squirrel", + /// "elk" + /// ] + /// print(animals.contains(exactly: 2, where: { $0 == "bear" })) + /// // prints "false" + /// + /// Using `contains(exactly:where:)` is faster than using `filter(where:)` and + /// comparing its `count` using `==` because this function can return early, + /// without needing to iterating through all elements to get an exact count. + /// If, and as soon as, the count exceeds 2, it returns `false`. + /// + /// - Parameter exactCount: The exact number to expect + /// - Parameter predicate: A closure that takes an element as its argument and + /// returns a Boolean value indicating whether the element should be included + /// in the count. + /// - Returns: Whether or not exactly `exactCount` number of elements in the + /// sequence passed `predicate` + /// - Complexity: Worst case O(*n*), where *n* is the number of elements. + @inlinable + public func contains( + exactly exactCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool { + return try self.contains(countIn: exactCount...exactCount, where: predicate) + } + + /// Returns whether or not at least a given number of elements of the sequence + /// satisfy the given predicate. + /// + /// The following example determines if there are at least two numbers that + /// are a multiple of 3: + /// + /// let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + /// print(numbers.contains(atLeast: 2, where: { $0.isMultiple(of: 3) })) + /// // prints "true" + /// + /// Using `contains(atLeast:where:)` is faster than using `filter(where:)` and + /// comparing its `count` using `>=` because this function can return early, + /// without needing to iterating through all elements to get an exact count. + /// If, and as soon as, the count reaches 2, it returns `true`. + /// + /// - Parameter minimumCount: The minimum number to count before returning + /// - Parameter predicate: A closure that takes an element as its argument and + /// returns a Boolean value indicating whether the element should be included + /// in the count. + /// - Returns: Whether or not at least `minimumCount` number of elements in + /// the sequence passed `predicate` + /// - Complexity: Worst case O(*n*), where *n* is the number of elements. + @inlinable + public func contains( + atLeast minimumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool { + return try self.contains(countIn: minimumCount..., where: predicate) + } + + /// Returns whether or not more than a given number of elements of the + /// sequence satisfy the given predicate. + /// + /// The following example determines if there are more than two numbers that + /// are a multiple of 3: + /// + /// let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + /// print(numbers.contains(moreThan: 2, where: { $0.isMultiple(of: 3) })) + /// // prints "true" + /// + /// Using `contains(moreThan:where:)` is faster than using `filter(where:)` + /// and comparing its `count` using `>` because this function can return + /// early, without needing to iterating through all elements to get an exact + /// count. If, and as soon as, the count reaches 2, it returns `true`. + /// + /// - Parameter minimumCount: The minimum number to count before returning + /// - Parameter predicate: A closure that takes an element as its argument and + /// returns a Boolean value indicating whether the element should be included + /// in the count. + /// - Returns: Whether or not more than `minimumCount` number of elements in + /// the sequence passed `predicate` + /// - Complexity: Worst case O(*n*), where *n* is the number of elements. + @inlinable + public func contains( + moreThan minimumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool { + return try self.contains(countIn: (minimumCount + 1)..., where: predicate) + } + + /// Returns whether or not fewer than a given number of elements of the + /// sequence satisfy the given predicate. + /// + /// The following example determines if there are fewer than five numbers in + /// the sequence that are multiples of 10: + /// + /// let numbers = [1, 2, 5, 10, 20, 50, 100, 1, 1, 5, 2] + /// print(numbers.contains(lessThan: 5, where: { $0.isMultiple(of: 10) })) + /// // prints "true" + /// + /// Using `contains(moreThan:where:)` is faster than using `filter(where:)` + /// and comparing its `count` using `>` because this function can return + /// early, without needing to iterating through all elements to get an exact + /// count. If, and as soon as, the count reaches 2, it returns `true`. + /// + /// - Parameter maximumCount: The maximum number to count before returning + /// - Parameter predicate: A closure that takes an element as its argument and + /// returns a Boolean value indicating whether the element should be included + /// in the count. + /// - Returns: Whether or not less than `maximumCount` number of elements in + /// the sequence passed `predicate` + /// - Complexity: Worst case O(*n*), where *n* is the number of elements. + @inlinable + public func contains( + lessThan maximumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool { + return try self.contains(countIn: ..` because this function + /// can return early, without needing to iterating through all elements to get + /// an exact count. If, and as soon as, the count exceeds `maximumCount`, + /// it returns `false`. + /// + /// - Parameter maximumCount: The maximum number to count before returning + /// - Parameter predicate: A closure that takes an element as its argument and + /// returns a Boolean value indicating whether the element should be included + /// in the count. + /// - Returns: Whether or not the number of elements that pass `predicate` is + /// less than or equal to `maximumCount` + /// the sequence passed `predicate` + /// - Complexity: Worst case O(*n*), where *n* is the number of elements. + @inlinable + public func contains( + lessThanOrEqualTo maximumCount: Int, + where predicate: (Element) throws -> Bool + ) rethrows -> Bool { + return try self.contains(countIn: ...maximumCount, where: predicate) + } +} diff --git a/Tests/SwiftAlgorithmsTests/ContainsCountWhereTests.swift b/Tests/SwiftAlgorithmsTests/ContainsCountWhereTests.swift new file mode 100644 index 00000000..6b685898 --- /dev/null +++ b/Tests/SwiftAlgorithmsTests/ContainsCountWhereTests.swift @@ -0,0 +1,160 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Algorithms open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import XCTest +import Algorithms + +final class ContainsCountsWhereTests: XCTestCase { + func testCountInRangeDocumentationExample() { + let animals = [ + "mountain lion", + "lion", + "snow leopard", + "leopard", + "tiger", + "panther", + "jaguar" + ] + XCTAssertTrue(animals.contains(countIn: 2..., where: { $0.contains("lion") })) + + XCTAssertTrue(animals.contains(atLeast: 2, where: { $0.contains("lion") })) + XCTAssertTrue(animals.contains(exactly: 2, where: { $0.contains("lion") })) + XCTAssertTrue(animals.contains(lessThanOrEqualTo: 2, where: { $0.contains("lion") })) + XCTAssertTrue(animals.contains(lessThan: 3, where: { $0.contains("lion") })) + } + + func testCountInRange() { + let vowels = Set(["A", "E", "I", "O", "U", "Y"]) + + let a = ["A", "B", "C", "D", "E", "F", "G", "I"] + XCTAssertTrue(a.contains(countIn: 2...3, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(countIn: ...3, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(countIn: ..<4, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(countIn: 3..., where: { vowels.contains($0) })) + + let b = ["A", "B", "C"] + XCTAssertFalse(b.contains(countIn: 2...3, where: { vowels.contains($0) })) + XCTAssertFalse(b.contains(countIn: 2..., where: { vowels.contains($0) })) + XCTAssertTrue(b.contains(countIn: ...3, where: { vowels.contains($0) })) + XCTAssertTrue(b.contains(countIn: ...1, where: { vowels.contains($0) })) + } + + func testCountIsExactlyDocumentationExample() { + let animals = [ + "bear", + "fox", + "bear", + "squirrel", + "bear", + "moose", + "squirrel", + "elk" + ] + XCTAssertFalse(animals.contains(exactly: 2, where: { $0 == "bear" })) + } + + func testCountIsExactly() { + let vowels = Set(["A", "E", "I", "O", "U", "Y"]) + + let a = ["A", "", "A", "B", "C", "D", "E", "F", "G", "I"] + XCTAssertTrue(a.contains(exactly: 1, where: { $0 == "" })) + XCTAssertTrue(a.contains(exactly: 2, where: { $0 == "A" })) + XCTAssertTrue(a.contains(exactly: 4, where: { vowels.contains($0) })) + } + + func testCountIsAtLeastDocumentationExample() { + let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + XCTAssertTrue(numbers.contains(atLeast: 2, where: { $0.isMultiple(of: 3) })) + } + + func testCountIsAtLeast() { + let vowels = Set(["A", "E", "I", "O", "U", "Y"]) + + let a = ["A", "A", "", "B", "C", "D", "E", "F", "G", "I"] + XCTAssertTrue(a.contains(atLeast: 1, where: { $0 == "" })) + XCTAssertTrue(a.contains(atLeast: 2, where: { $0 == "A" })) + XCTAssertFalse(a.contains(atLeast: 3, where: { $0 == "A" })) + XCTAssertTrue(a.contains(atLeast: 2, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(atLeast: 3, where: { vowels.contains($0) })) + XCTAssertFalse(a.contains(atLeast: 1, where: { $0 == "Z" })) + } + + func testCountIsMoreThanDocumentationExample() { + let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + XCTAssertTrue(numbers.contains(moreThan: 2, where: { $0.isMultiple(of: 3) })) + } + + func testCountIsMoreThan() { + let vowels = Set(["A", "E", "I", "O", "U", "Y"]) + + let a = ["A", "A", "", "B", "C", "D", "E", "F", "G", "I"] + XCTAssertTrue(a.contains(moreThan: 0, where: { $0 == "" })) + XCTAssertFalse(a.contains(moreThan: 1, where: { $0 == "" })) + XCTAssertTrue(a.contains(moreThan: 1, where: { $0 == "A" })) + XCTAssertFalse(a.contains(moreThan: 2, where: { $0 == "A" })) + XCTAssertTrue(a.contains(moreThan: 2, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(moreThan: 3, where: { vowels.contains($0) })) + XCTAssertFalse(a.contains(moreThan: 4, where: { vowels.contains($0) })) + XCTAssertFalse(a.contains(moreThan: 1, where: { $0 == "Z" })) + } + + func testCountIsLessThanDocumentationExample() { + let numbers = [1, 2, 5, 10, 20, 50, 100, 1, 1, 5, 2] + XCTAssertTrue(numbers.contains(lessThan: 5, where: { $0.isMultiple(of: 10) })) + } + + func testCountIsLessThan() { + let vowels = Set(["A", "E", "I", "O", "U", "Y"]) + + let a = ["A", "A", "", "B", "C", "D", "E", "F", "G", "I"] + XCTAssertFalse(a.contains(lessThan: 1, where: { $0 == "" })) + XCTAssertTrue(a.contains(lessThan: 3, where: { $0 == "A" })) + XCTAssertFalse(a.contains(lessThan: 2, where: { $0 == "A" })) + XCTAssertTrue(a.contains(lessThan: 5, where: { vowels.contains($0) })) + XCTAssertFalse(a.contains(lessThan: 4, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(lessThan: 1, where: { $0 == "Z" })) + } + + func testCountIsLessThanOrEqualToDocumentationExample() { + let numbers = [1, 2, 5, 10, 20, 50, 100, 1000, 1, 1, 5, 2] + XCTAssertTrue(numbers.contains(lessThanOrEqualTo: 5, where: { $0.isMultiple(of: 10) })) + } + + func testCountIsLessThanOrEqualTo() { + let vowels = Set(["A", "E", "I", "O", "U", "Y"]) + + let a = ["A", "A", "", "B", "C", "D", "E", "F", "G", "I"] + XCTAssertTrue(a.contains(lessThanOrEqualTo: 1, where: { $0 == "" })) + XCTAssertTrue(a.contains(lessThanOrEqualTo: 2, where: { $0 == "A" })) + XCTAssertFalse(a.contains(lessThanOrEqualTo: 1, where: { $0 == "A" })) + XCTAssertTrue(a.contains(lessThanOrEqualTo: 4, where: { vowels.contains($0) })) + XCTAssertFalse(a.contains(lessThanOrEqualTo: 3, where: { vowels.contains($0) })) + XCTAssertTrue(a.contains(lessThanOrEqualTo: 1, where: { $0 == "Z" })) + } + + func testZeroCount() { + let a = ["A", "B", "C"] + XCTAssertTrue(a.contains(atLeast: 0, where: { _ in false })) + XCTAssertTrue(a.contains(atLeast: 0, where: { _ in true })) + XCTAssertFalse(a.contains(lessThan: 0, where: { _ in false })) + XCTAssertFalse(a.contains(lessThan: 0, where: { _ in true })) + XCTAssertTrue(a.contains(lessThanOrEqualTo: 0, where: { _ in false })) + XCTAssertFalse(a.contains(lessThanOrEqualTo: 0, where: { _ in true })) + + let b = [String]() + XCTAssertTrue(b.contains(atLeast: 0, where: { _ in false })) + XCTAssertTrue(b.contains(atLeast: 0, where: { _ in true })) + XCTAssertFalse(b.contains(lessThan: 0, where: { _ in false })) + XCTAssertFalse(b.contains(lessThan: 0, where: { _ in true })) + XCTAssertTrue(b.contains(lessThanOrEqualTo: 0, where: { _ in false })) + XCTAssertTrue(b.contains(lessThanOrEqualTo: 0, where: { _ in true })) + } +}