-
Notifications
You must be signed in to change notification settings - Fork 449
Add grouped(by:)
and keyed(by:)
#197
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
Changes from all commits
33a2553
f3b1dd8
8c7df22
4ca6551
1a70760
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,68 @@ | ||
# Grouped | ||
|
||
[[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Grouped.swift) | | ||
[Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/GroupedTests.swift)] | ||
|
||
Groups up elements of a sequence into a new Dictionary, whose values are Arrays of grouped elements, each keyed by the result of the given closure. | ||
|
||
```swift | ||
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Avocado", "Coconut"] | ||
let fruitsByLetter = fruits.grouped(by: { $0.first! }) | ||
// Results in: | ||
// [ | ||
// "B": ["Banana"], | ||
// "A": ["Apricot", "Apple", "Avocado"], | ||
// "C": ["Cherry", "Coconut"], | ||
// ] | ||
``` | ||
|
||
If you wish to achieve a similar effect but for single values (instead of Arrays of grouped values), see [`keyed(by:)`](Keyed.md). | ||
|
||
## Detailed Design | ||
|
||
The `grouped(by:)` method is declared as a `Sequence` extension returning | ||
`[GroupKey: [Element]]`. | ||
|
||
```swift | ||
extension Sequence { | ||
public func grouped<GroupKey>( | ||
by keyForValue: (Element) throws -> GroupKey | ||
) rethrows -> [GroupKey: [Element]] | ||
} | ||
``` | ||
|
||
### Complexity | ||
|
||
Calling `grouped(by:)` is an O(_n_) operation. | ||
|
||
### Comparison with other languages | ||
|
||
| Language | Grouping API | | ||
|---------------|--------------| | ||
| Java | [`groupingBy`](https://docs.oracle.com/en/java/javase/20/docs/api/java.base/java/util/stream/Collectors.html#groupingBy(java.util.function.Function)) | | ||
| Kotlin | [`groupBy`](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/group-by.html) | | ||
| C# | [`GroupBy`](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.groupby?view=net-7.0#system-linq-enumerable-groupby) | | ||
| Rust | [`group_by`](https://doc.rust-lang.org/std/primitive.slice.html#method.group_by) | | ||
| Ruby | [`group_by`](https://ruby-doc.org/3.2.2/Enumerable.html#method-i-group_by) | | ||
| Python | [`groupby`](https://docs.python.org/3/library/itertools.html#itertools.groupby) | | ||
| PHP (Laravel) | [`groupBy`](https://laravel.com/docs/10.x/collections#method-groupby) | | ||
|
||
#### Naming | ||
|
||
All the surveyed languages name this operation with a variant of "grouped" or "grouping". The past tense `grouped(by:)` best fits [Swift's API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/). | ||
|
||
#### Customization points | ||
|
||
Java and C# are interesting in that they provide multiple overloads with several points of customization: | ||
|
||
1. Changing the type of the groups. | ||
1. E.g. the groups can be Sets instead of Arrays. | ||
1. Akin to calling `.transformValues { group in Set(group) }` on the resultant dictionary, but avoiding the intermediate allocation of Arrays of each group. | ||
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. ForwardI explored all this really just for my own edification, but it might be helpful to include some summary of this in your notes here, to explain why the Rabbit holeI like the idea of supporting this, but it doesn't seem like it's possible [elegantly] in Swift today…? Swift's standard library & Foundation don't include an equivalent to Java's Specifically: you can easily genericise There is extension Sequence {
public func grouped<GroupKey, ValueCollection: RangeReplaceableCollection<Element>>(
by keyForValue: (Element) throws -> GroupKey
) rethrows -> [GroupKey: ValueCollection] {
var result = [GroupKey: ValueCollection]()
for value in self {
result[try keyForValue(value), default: ValueCollection()].append(value)
}
return result
}
} Semantically it's a hack, of course - there's nothing about the range replacement functionality actually needed here, it's just used because it exposes other APIs almost as a side-effect. And anyway, the ergonomics are poor since Swift doesn't support specifying a default value for a generic parameter, requiring boilerplate in many cases: [1, 2, 3, 4].grouped { $0 % 2 } // ❌ Generic parameter 'ValueCollection' could not be inferred
let result: [Int: [Int]] = [1, 2, 3, 4].grouped { $0 % 2 } // Works, but awkward for many use-cases. One could work around the initialisation problem by having the caller explicitly provide an initialiser, but you still need a protocol that all collections support which specifies some kind of It is possible to support arbitrary collection types, but only by having the caller provide a reducer explicitly in order to work around the aforementioned limitations, e.g.: extension Sequence {
public func grouped<GroupKey, ValueCollection: Collection>(
by keyForValue: (Element) throws -> GroupKey,
collectionInitialiser: () -> ValueCollection = Array<Element>.init,
collectionReducer: (inout ValueCollection, Element) -> ValueCollection
) rethrows -> [GroupKey: ValueCollection] where ValueCollection.Element == Element {
var result = [GroupKey: ValueCollection]()
for value in self {
collectionReducer(&result[try keyForValue(value), default: collectionInitialiser()], value)
}
return result
}
}
let result: [Int: [Int]] = [1, 2, 3, 4].grouped(by: { $0 % 2 },
collectionReducer: { $0.append($1); return $0 }) This is essentially now a functional superset of You can't provide a default reducer since there's no way to generically add a value to any type of collection (per the earlier point). So this'd have to be a special parallel version of group(by:), in addition to the 'simple' one that just hard-codes use of I'm not a fan of this approach, technically capable as it may be, though admittedly that's from a subjective standpoint. I think it'd be more elegant, and cleaner for library authors, to evolve Swift (or the Swift standard library) to better support generalising across all collection types. 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. Very interesting findings, thanks for sharing this with me! How do you think it would be best to communicate these in the library? Perhaps an addendum to the new md files I'm adding in this PR?
Oh wow, I never noticed this, but you're right!
This is what I had in mind (or use a new protocol with method requirements, instead of two separate closure params). This is more general, because the groups might not be collections at all. The histogram example comes to mind, where you'd want something like: [1, 2, 3, 4].grouped(
by: { $0 % 2 },
collectionInitialiser: { 0 }, // not a collection, but w/e
collectionReducer: { counter, _ in counter += 1 },
) If there were a protocol for this, then there might be some C# and Java's APIs are particularly interesting in this area, though I think a lot of their designs were constrained by the lack of tuples (at the time of their design). 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'm not sure how best to summarise that - obviously what I wrote, as a kind of deep-dive on this tangent, is way too much for these documents. Maybe it suffices to say something like "This would require either (a) a new protocol, supported by all standard library collection types, which represents an initialisable object that can be added to, or (b) a variant method which accepts a custom 'reducer' closure". If you want to say anything about it at all. Perhaps this thread here on the pull request is sufficient documentation for posterity (which could be hyperlinked to from the markdown file). Having used Java's version of this a few times in real-world code, my main comment is that these sorts of APIs - groupby etc - should never require explicit specification of the return types (or a custom reducer). They can allow it, through overloads or optional parameters, but it needs to work effortlessly for the vastly most common case of just returning a Dictionary of Arrays. A lot of Java APIs explicitly require you to pass a collector which is almost always 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. Also, as a counter-argument (Devil's advocate style) for the use of this custom-reducer [1, 2, 3, 4].reduce(into: [Int: Int]()) { $0[$1, default: 0] += 1 } (and the explicit typing of the Personally I don't mind if a language or library provides multiple ways to achieve the same goals, but I'm not sure a fancier That said, you can implement [1, 2, 3, 4].reduce(into: [Int: [Int]]()) {
$0[$1 % 2, default: []].append($1)
} …yet I still feel like a purpose-built Counter-counter-argument: even though 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.
Likewise. I have conviction about the base
Heh I have an entire little blog post about this: https://github.com/amomchilov/Blog/blob/master/Don't%20abuse%20reduce.md 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. Is this research something you'd like to commit? You deserve to have your name on your findings :) |
||
2. Picking which elements end up in the groupings. | ||
1. The default is the elements of the input sequence, but can be changed. | ||
2. Akin to calling `.transformValues { group in group.map(someTransform) }` on the resultant dictionary, but avoiding the intermediate allocation of Arrays of each group. | ||
Comment on lines
+61
to
+63
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. Side thought: If 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'd still be notably less efficient, due (mainly) to the construction of the intermediary arrays. I assume the Swift compiler won't be clever enough to eliminate those. Aside from that, how would lazy execution work? Wouldn't you have to run through the complete input sequence in order to know when you have all the values for any given key? |
||
3. Changing the type of the outermost collection. | ||
1. E.g using an `OrderedDictionary`, `SortedDictionary` or `TreeDictionary` instead of the default (hashed, unordered) `Dictionary`. | ||
2. There's no great way to achieve this with the `grouped(by:)`. One could wrap the resultant dictionary in an initializer to one of the other dictionary types, but that isn't sufficient: Once the `Dictionary` loses the ordering, there's no way to get it back when constructing one of the ordered dictionary variants. | ||
|
||
It is not clear which of these points of customization are worth supporting, or what the best way to express them might be. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
# Keyed | ||
|
||
[[Source](https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Keyed.swift) | | ||
[Tests](https://github.com/apple/swift-algorithms/blob/main/Tests/SwiftAlgorithmsTests/KeyedTests.swift)] | ||
|
||
Stores the elements of a sequence as the values of a Dictionary, keyed by the result of the given closure. | ||
|
||
```swift | ||
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Blackberry", "Avocado", "Coconut"] | ||
let fruitByLetter = fruits.keyed(by: { $0.first! }) | ||
// Results in: | ||
// [ | ||
// "A": "Avocado", | ||
// "B": "Blackberry", | ||
// "C": "Coconut", | ||
// ] | ||
``` | ||
|
||
On a key-collision, the latest element is kept by default. Alternatively, you can provide a closure which specifies which value to keep: | ||
|
||
```swift | ||
let fruits = ["Apricot", "Banana", "Apple", "Cherry", "Blackberry", "Avocado", "Coconut"] | ||
let fruitsByLetter = fruits.keyed( | ||
by: { $0.first! }, | ||
resolvingConflictsWith: { key, old, new in old } // Always pick the first fruit | ||
) | ||
// Results in: | ||
// [ | ||
// "A": "Apricot", | ||
// "B": "Banana", | ||
// "C": "Cherry", | ||
// ] | ||
``` | ||
|
||
## Detailed Design | ||
|
||
The `keyed(by:)` and `keyed(by:resolvingConflictsWith:)` methods are declared in an `Sequence` extension, both returning `[Key: Element]`. | ||
|
||
```swift | ||
extension Sequence { | ||
public func keyed<Key>( | ||
by keyForValue: (Element) throws -> Key | ||
) rethrows -> [Key: Element] | ||
|
||
public func keyed<Key>( | ||
by keyForValue: (Element) throws -> Key, | ||
resolvingConflictsWith resolve: ((Key, Element, Element) throws -> Element)? = nil | ||
) rethrows -> [Key: Element] | ||
} | ||
``` | ||
|
||
### Complexity | ||
|
||
Calling `keyed(by:)` is an O(_n_) operation. | ||
|
||
### Comparison with other languages | ||
|
||
| Language | "Keying" API | | ||
|---------------|-------------| | ||
| Java | [`toMap`](https://docs.oracle.com/en/java/javase/20/docs/api/java.base/java/util/stream/Collectors.html#toMap(java.util.function.Function,java.util.function.Function)) | | ||
| Kotlin | [`associatedBy`](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/associate-by.html) | | ||
| C# | [`ToDictionary`](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.todictionary?view=net-7.0#system-linq-enumerable-todictionary) | | ||
| Ruby (ActiveSupport) | [`index_by`](https://rubydoc.info/gems/activesupport/7.0.5/Enumerable#index_by-instance_method) | | ||
| PHP (Laravel) | [`keyBy`](https://laravel.com/docs/10.x/collections#method-keyby) | | ||
|
||
#### Rejected alternative names | ||
|
||
1. Java's `toMap` is referring to `Map`/`HashMap`, their naming for Dictionaries and other associative collections. It's easy to confuse with the transformation function, `Sequence.map(_:)`. | ||
2. C#'s `toXXX()` naming doesn't suite Swift well, which tends to prefer `Foo.init` over `toFoo()` methods. | ||
3. Ruby's `index_by` naming doesn't fit Swift well, where "index" is a specific term (e.g. the `associatedtype Index` on `Collection`). There is also a [`index(by:)`](Index.md) method in swift-algorithms, is specifically to do with matching elements up with their indices, and not any arbitrary derived value. | ||
|
||
#### Alternative names | ||
|
||
Kotlin's `associatedBy` naming is a good alterative, and matches the past tense of [Swift's API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/), though perhaps we'd spell it `associated(by:)`. | ||
|
||
#### Customization points | ||
|
||
Java and C# are interesting in that they provide overloads that let you customize the type of the outermost collection. E.g. using an `OrderedDictionary` instead of the default (hashed, unordered) `Dictionary`. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// This source file is part of the Swift Algorithms open source project | ||
// | ||
// Copyright (c) 2021 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 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
extension Sequence { | ||
/// Groups up elements of `self` into a new Dictionary, | ||
/// whose values are Arrays of grouped elements, | ||
/// each keyed by the group key returned by the given closure. | ||
/// - Parameters: | ||
/// - keyForValue: A closure that returns a key for each element in | ||
/// `self`. | ||
/// - Returns: A dictionary containing grouped elements of self, keyed by | ||
/// the keys derived by the `keyForValue` closure. | ||
@inlinable | ||
public func grouped<GroupKey>(by keyForValue: (Element) throws -> GroupKey) rethrows -> [GroupKey: [Element]] { | ||
try Dictionary(grouping: self, by: keyForValue) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// 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 | ||
// | ||
//===----------------------------------------------------------------------===// | ||
|
||
extension Sequence { | ||
/// Creates a new Dictionary from the elements of `self`, keyed by the | ||
/// results returned by the given `keyForValue` closure. | ||
/// | ||
/// If the key derived for a new element collides with an existing key from a previous element, | ||
/// the latest value will be kept. | ||
/// | ||
/// - Parameters: | ||
/// - keyForValue: A closure that returns a key for each element in `self`. | ||
@inlinable | ||
public func keyed<Key>( | ||
by keyForValue: (Element) throws -> Key | ||
) rethrows -> [Key: Element] { | ||
return try self.keyed(by: keyForValue, resolvingConflictsWith: { _, old, new in new }) | ||
} | ||
|
||
/// Creates a new Dictionary from the elements of `self`, keyed by the | ||
/// results returned by the given `keyForValue` closure. As the dictionary is | ||
/// built, the initializer calls the `resolve` closure with the current and | ||
/// new values for any duplicate keys. Pass a closure as `resolve` that | ||
/// returns the value to use in the resulting dictionary: The closure can | ||
/// choose between the two values, combine them to produce a new value, or | ||
/// even throw an error. | ||
/// | ||
/// - Parameters: | ||
/// - keyForValue: A closure that returns a key for each element in `self`. | ||
/// - resolve: A closure that is called with the values for any duplicate | ||
/// keys that are encountered. The closure returns the desired value for | ||
/// the final dictionary. | ||
@inlinable | ||
public func keyed<Key>( | ||
by keyForValue: (Element) throws -> Key, | ||
resolvingConflictsWith resolve: (Key, Element, Element) throws -> Element | ||
) rethrows -> [Key: Element] { | ||
var result = [Key: Element]() | ||
|
||
for element in self { | ||
let key = try keyForValue(element) | ||
|
||
if let oldValue = result.updateValue(element, forKey: key) { | ||
let valueToKeep = try resolve(key, oldValue, element) | ||
|
||
// This causes a second look-up for the same key. The standard library can avoid that | ||
// by calling `mutatingFind` to get access to the bucket where the value will end up, | ||
// and updating in place. | ||
// Swift Algorithms doesn't have access to that API, so we make do. | ||
// When this gets merged into the standard library, we should optimize this. | ||
result[key] = valueToKeep | ||
} | ||
} | ||
|
||
return result | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// 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 GroupedTests: XCTestCase { | ||
private class SampleError: Error {} | ||
|
||
// Based on https://github.com/apple/swift/blob/4d1d8a9de5ebc132a17aee9fc267461facf89bf8/validation-test/stdlib/Dictionary.swift#L1974-L1988 | ||
|
||
func testGroupedBy() { | ||
let r = 0..<10 | ||
|
||
let d1 = r.grouped(by: { $0 % 3 }) | ||
XCTAssertEqual(3, d1.count) | ||
XCTAssertEqual(d1[0]!, [0, 3, 6, 9]) | ||
XCTAssertEqual(d1[1]!, [1, 4, 7]) | ||
XCTAssertEqual(d1[2]!, [2, 5, 8]) | ||
|
||
let d2 = r.grouped(by: { $0 }) | ||
XCTAssertEqual(10, d2.count) | ||
|
||
let d3 = (0..<0).grouped(by: { $0 }) | ||
XCTAssertEqual(0, d3.count) | ||
} | ||
|
||
func testThrowingFromKeyFunction() { | ||
let input = ["Apple", "Banana", "Cherry"] | ||
let error = SampleError() | ||
|
||
XCTAssertThrowsError( | ||
try input.grouped(by: { (_: String) -> Character in throw error }) | ||
) { thrownError in | ||
XCTAssertIdentical(error, thrownError as? SampleError) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
//===----------------------------------------------------------------------===// | ||
// | ||
// 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 KeyedTests: XCTestCase { | ||
private class SampleError: Error {} | ||
|
||
func testUniqueKeys() { | ||
let d = ["Apple", "Banana", "Cherry"].keyed(by: { $0.first! }) | ||
XCTAssertEqual(d.count, 3) | ||
XCTAssertEqual(d["A"]!, "Apple") | ||
XCTAssertEqual(d["B"]!, "Banana") | ||
XCTAssertEqual(d["C"]!, "Cherry") | ||
XCTAssertNil(d["D"]) | ||
} | ||
|
||
func testEmpty() { | ||
let d = EmptyCollection<String>().keyed(by: { $0.first! }) | ||
XCTAssertEqual(d.count, 0) | ||
} | ||
|
||
func testNonUniqueKeys() throws { | ||
let d = ["Apple", "Avocado", "Banana", "Cherry"].keyed(by: { $0.first! }) | ||
XCTAssertEqual(d.count, 3) | ||
XCTAssertEqual(d["A"]!, "Avocado", "On a key-collision, keyed(by:) should take the latest value.") | ||
XCTAssertEqual(d["B"]!, "Banana") | ||
XCTAssertEqual(d["C"]!, "Cherry") | ||
} | ||
|
||
func testNonUniqueKeysWithMergeFunction() { | ||
var resolveCallHistory = [(key: Character, current: String, new: String)]() | ||
let expectedCallHistory = [ | ||
(key: "A", current: "Apple", new: "Avocado"), | ||
(key: "C", current: "Cherry", new: "Coconut"), | ||
] | ||
|
||
let d = ["Apple", "Avocado", "Banana", "Cherry", "Coconut"].keyed( | ||
by: { $0.first! }, | ||
resolvingConflictsWith: { key, older, newer in | ||
resolveCallHistory.append((key, older, newer)) | ||
return "\(older)-\(newer)" | ||
} | ||
) | ||
|
||
XCTAssertEqual(d.count, 3) | ||
XCTAssertEqual(d["A"]!, "Apple-Avocado") | ||
XCTAssertEqual(d["B"]!, "Banana") | ||
XCTAssertEqual(d["C"]!, "Cherry-Coconut") | ||
XCTAssertNil(d["D"]) | ||
|
||
XCTAssertEqual( | ||
resolveCallHistory.map(String.init(describing:)), // quick/dirty workaround: tuples aren't Equatable | ||
expectedCallHistory.map(String.init(describing:)) | ||
) | ||
} | ||
|
||
func testThrowingFromKeyFunction() { | ||
let input = ["Apple", "Banana", "Cherry"] | ||
let error = SampleError() | ||
|
||
XCTAssertThrowsError( | ||
try input.keyed(by: { (_: String) -> Character in throw error }) | ||
) { thrownError in | ||
XCTAssertIdentical(error, thrownError as? SampleError) | ||
} | ||
} | ||
|
||
func testThrowingFromCombineFunction() { | ||
let input = ["Apple", "Avocado", "Banana", "Cherry"] | ||
let error = SampleError() | ||
|
||
XCTAssertThrowsError( | ||
try input.keyed(by: { $0.first! }, resolvingConflictsWith: { _, _, _ in throw error }) | ||
) { thrownError in | ||
XCTAssertIdentical(error, thrownError as? SampleError) | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.