├── .editorconfig ├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── benchmark.png └── workflows │ ├── ci.yml │ ├── documentation.yml │ ├── format.yml │ └── release.yml ├── .gitignore ├── .spi.yml ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── Package@swift-6.0.swift ├── README.md ├── Sources ├── IdentifiedCollections │ ├── Documentation.docc │ │ ├── IdentifiedCollections.md │ │ └── Resources │ │ │ └── benchmark.png │ ├── Identified │ │ └── Identified.swift │ ├── IdentifiedArray │ │ ├── IdentifiedArray+Codable.swift │ │ ├── IdentifiedArray+Collection.swift │ │ ├── IdentifiedArray+CollectionAlgorithms.swift │ │ ├── IdentifiedArray+CustomDebugStringConvertible.swift │ │ ├── IdentifiedArray+CustomReflectable.swift │ │ ├── IdentifiedArray+CustomStringConvertible.swift │ │ ├── IdentifiedArray+Equatable.swift │ │ ├── IdentifiedArray+ExpressibleByArrayLiteral.swift │ │ ├── IdentifiedArray+Hashable.swift │ │ ├── IdentifiedArray+IdentifiedCollection.swift │ │ ├── IdentifiedArray+Initializers.swift │ │ ├── IdentifiedArray+Insertions.swift │ │ ├── IdentifiedArray+MutableCollection.swift │ │ ├── IdentifiedArray+RandomAccessCollection.swift │ │ ├── IdentifiedArray+RangeReplaceableCollection.swift │ │ ├── IdentifiedArray+Sendable.swift │ │ └── IdentifiedArray.swift │ └── IdentifiedCollection.swift └── swift-identified-collections-benchmark │ └── main.swift └── Tests └── IdentifiedCollectionsTests ├── IdentifiedArrayCollectionOperationTests.swift └── IdentifiedArrayTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Something isn't working as expected 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for contributing to Identified Collections! 9 | 10 | Before you submit your issue, please complete each text area below with the relevant details for your bug, and complete the steps in the checklist 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: | 15 | A short description of the incorrect behavior. 16 | 17 | If you think this issue has been recently introduced and did not occur in an earlier version, please note that. If possible, include the last version that the behavior was correct in addition to your current version. 18 | validations: 19 | required: true 20 | - type: checkboxes 21 | attributes: 22 | label: Checklist 23 | options: 24 | - label: If possible, I've reproduced the issue using the `main` branch of this package. 25 | required: false 26 | - label: This issue hasn't been addressed in an [existing GitHub issue](https://github.com/pointfreeco/swift-identified-collections/issues) or [discussion](https://github.com/pointfreeco/swift-identified-collections/discussions). 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Expected behavior 31 | description: Describe what you expected to happen. 32 | validations: 33 | required: false 34 | - type: textarea 35 | attributes: 36 | label: Actual behavior 37 | description: Describe or copy/paste the behavior you observe. 38 | validations: 39 | required: false 40 | - type: textarea 41 | attributes: 42 | label: Steps to reproduce 43 | description: | 44 | Explanation of how to reproduce the incorrect behavior. 45 | 46 | This could include an attached project or link to code that is exhibiting the issue, and/or a screen recording. 47 | placeholder: | 48 | 1. ... 49 | validations: 50 | required: false 51 | - type: input 52 | attributes: 53 | label: Identified Collections version information 54 | description: The version of Identified Collections used to reproduce this issue. 55 | placeholder: "'0.5.0' for example, or a commit hash" 56 | - type: input 57 | attributes: 58 | label: Destination operating system 59 | description: The OS running your Identified Collections code. 60 | placeholder: "'iOS 16' for example" 61 | - type: input 62 | attributes: 63 | label: Xcode version information 64 | description: The version of Xcode used to reproduce this issue. 65 | placeholder: "The version displayed from 'Xcode 〉About Xcode'" 66 | - type: textarea 67 | attributes: 68 | label: Swift Compiler version information 69 | description: The version of Swift used to reproduce this issue. 70 | placeholder: Output from 'xcrun swiftc --version' 71 | render: shell 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Discussion 5 | url: https://github.com/pointfreeco/swift-identified-collections/discussions 6 | about: Identified Collections Q&A, ideas, and more 7 | - name: Documentation 8 | url: https://pointfreeco.github.io/swift-identified-collections/main/documentation/identifiedcollections/ 9 | about: Read Identified Collections' documentation 10 | - name: Videos 11 | url: https://www.pointfree.co/ 12 | about: Watch videos to get a behind-the-scenes look at how Identified Collections was motivated and built 13 | -------------------------------------------------------------------------------- /.github/benchmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pointfreeco/swift-identified-collections/d9f9741112e86e6c62a376c76b4d4585b906c12d/.github/benchmark.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - '*' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: macOS 15 | runs-on: macos-14 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Select Xcode 15.4 19 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 20 | - name: Run tests 21 | run: make test-swift 22 | 23 | ubuntu: 24 | strategy: 25 | matrix: 26 | swift: 27 | - '5.10' 28 | name: Ubuntu (Swift ${{ matrix.swift }}) 29 | runs-on: ubuntu-latest 30 | container: swift:${{ matrix.swift }} 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Run tests 34 | run: swift test --parallel 35 | - name: Run tests (release) 36 | run: swift test -c release --parallel 37 | 38 | # windows: 39 | # name: Windows (Swift ${{ matrix.swift }}, ${{ matrix.config }}) 40 | # strategy: 41 | # matrix: 42 | # os: [windows-latest] 43 | # config: ['debug', 'release'] 44 | # fail-fast: false 45 | # runs-on: ${{ matrix.os }} 46 | # steps: 47 | # - uses: compnerd/gha-setup-swift@main 48 | # with: 49 | # branch: swift-5.10-release 50 | # tag: 5.10-RELEASE 51 | # - uses: actions/checkout@v4 52 | # - name: Build 53 | # run: swift build -c ${{ matrix.config }} 54 | # - name: Run tests (debug only) 55 | # if: ${{ matrix.config == 'debug' }} 56 | # run: swift test 57 | 58 | android: 59 | name: Android (Swift 6.0.2) 60 | runs-on: ubuntu-22.04 61 | steps: 62 | - name: Checkout Repository 63 | uses: actions/checkout@v4 64 | - name: Install Swift 65 | uses: tayloraswift/swift-install-action@master 66 | with: 67 | swift-prefix: "swift-6.0.2-release/ubuntu2204/swift-6.0.2-RELEASE" 68 | swift-id: "swift-6.0.2-RELEASE-ubuntu22.04" 69 | - name: Check Swift 70 | run: swift --version 71 | - name: Install Android SDK 72 | run: 73 | swift sdk install https://github.com/finagolfin/swift-android-sdk/releases/download/6.0.2/swift-6.0.2-RELEASE-android-24-0.1.artifactbundle.tar.gz --checksum d75615eac3e614131133c7cc2076b0b8fb4327d89dce802c25cd53e75e1881f4 74 | - name: Check Android SDK 75 | run: 76 | swift sdk configure --show-configuration swift-6.0.2-RELEASE-android-24-0.1 x86_64-unknown-linux-android24 77 | - name: Build Tests 78 | run: 79 | OMIT_MACRO_TESTS=1 swift build --build-tests --swift-sdk x86_64-unknown-linux-android24 -Xswiftc -Xclang-linker -Xswiftc -fuse-ld=lld 80 | - name: Prepare Android Emulator Test Script 81 | run: | 82 | mkdir pack 83 | cp .build/x86_64-unknown-linux-android24/debug/swift-identified-collectionsPackageTests.xctest pack 84 | 85 | cp /home/runner/.config/swiftpm/swift-sdks/swift-6.0.2-RELEASE-android-24-0.1.artifactbundle/swift-6.0.2-release-android-24-sdk/android-27c-sysroot/usr/lib/x86_64-linux-android/24/lib*.so pack 86 | rm pack/lib{c,dl,log,m,z}.so 87 | 88 | set -x 89 | cat > ~/test-toolchain.sh << EOF 90 | adb push pack /data/local/tmp 91 | adb shell /data/local/tmp/pack/swift-identified-collectionsPackageTests.xctest 92 | EOF 93 | 94 | chmod +x ~/test-toolchain.sh 95 | - name: Run Tests on Android Emulator 96 | uses: reactivecircus/android-emulator-runner@v2 97 | with: 98 | api-level: 29 99 | arch: x86_64 100 | script: ~/test-toolchain.sh 101 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: docs-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | build: 18 | runs-on: macos-12 19 | steps: 20 | - name: Select Xcode 14.1 21 | run: sudo xcode-select -s /Applications/Xcode_14.1.app 22 | 23 | - name: Checkout Package 24 | uses: actions/checkout@v2 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: Checkout gh-pages Branch 29 | uses: actions/checkout@v2 30 | with: 31 | ref: gh-pages 32 | path: docs-out 33 | 34 | - name: Build documentation 35 | run: > 36 | rm -rf docs-out/.git; 37 | rm -rf docs-out/main; 38 | git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {}; 39 | 40 | for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6); 41 | do 42 | if [ -d "docs-out/$tag/data/documentation/identifiedcollections" ] 43 | then 44 | echo "✅ Documentation for "$tag" already exists."; 45 | else 46 | echo "⏳ Generating documentation for IdentifiedCollections @ "$tag" release."; 47 | rm -rf "docs-out/$tag"; 48 | 49 | git checkout .; 50 | git checkout "$tag"; 51 | 52 | swift package \ 53 | --allow-writing-to-directory docs-out/"$tag" \ 54 | generate-documentation \ 55 | --target IdentifiedCollections \ 56 | --output-path docs-out/"$tag" \ 57 | --transform-for-static-hosting \ 58 | --hosting-base-path /swift-identified-collections/"$tag" \ 59 | && echo "✅ Documentation generated for IdentifiedCollections @ "$tag" release." \ 60 | || echo "⚠️ Documentation skipped for IdentifiedCollections @ "$tag"."; 61 | fi; 62 | done 63 | 64 | - name: Fix permissions 65 | run: 'sudo chown -R $USER docs-out' 66 | 67 | - name: Publish documentation to GitHub Pages 68 | uses: JamesIves/github-pages-deploy-action@4.1.7 69 | with: 70 | branch: gh-pages 71 | folder: docs-out 72 | single-commit: true 73 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | swift_format: 10 | name: swift-format 11 | runs-on: macOS-14 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Xcode Select 15 | run: sudo xcode-select -s /Applications/Xcode_15.4.app 16 | - name: Install 17 | run: brew install swift-format 18 | - name: Format 19 | run: make format 20 | - uses: stefanzweifel/git-auto-commit-action@v4 21 | with: 22 | commit_message: Run swift-format 23 | branch: 'main' 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | release: 4 | types: [published] 5 | workflow_dispatch: 6 | jobs: 7 | release-project-channel: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Dump Github context 11 | env: 12 | GITHUB_CONTEXT: ${{ toJSON(github) }} 13 | run: echo "$GITHUB_CONTEXT" 14 | - name: Slack Notification on SUCCESS 15 | if: success() 16 | uses: tokorom/action-slack-incoming-webhook@main 17 | env: 18 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_PROJECT_CHANNEL_WEBHOOK_URL }} 19 | with: 20 | text: swift-identified-collections ${{ github.event.release.tag_name }} has been released. 21 | blocks: | 22 | [ 23 | { 24 | "type": "header", 25 | "text": { 26 | "type": "plain_text", 27 | "text": "swift-identified-collections ${{ github.event.release.tag_name }}" 28 | } 29 | }, 30 | { 31 | "type": "section", 32 | "text": { 33 | "type": "mrkdwn", 34 | "text": ${{ toJSON(github.event.release.body) }} 35 | } 36 | }, 37 | { 38 | "type": "section", 39 | "text": { 40 | "type": "mrkdwn", 41 | "text": "${{ github.event.release.html_url }}" 42 | } 43 | } 44 | ] 45 | release-releases-channel: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Dump Github context 49 | env: 50 | GITHUB_CONTEXT: ${{ toJSON(github) }} 51 | run: echo "$GITHUB_CONTEXT" 52 | - name: Slack Notification on SUCCESS 53 | if: success() 54 | uses: tokorom/action-slack-incoming-webhook@main 55 | env: 56 | INCOMING_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }} 57 | with: 58 | text: swift-identified-collections ${{ github.event.release.tag_name }} has been released. 59 | blocks: | 60 | [ 61 | { 62 | "type": "header", 63 | "text": { 64 | "type": "plain_text", 65 | "text": "swift-identified-collections ${{ github.event.release.tag_name }}" 66 | } 67 | }, 68 | { 69 | "type": "section", 70 | "text": { 71 | "type": "mrkdwn", 72 | "text": ${{ toJSON(github.event.release.body) }} 73 | } 74 | }, 75 | { 76 | "type": "section", 77 | "text": { 78 | "type": "mrkdwn", 79 | "text": "${{ github.event.release.html_url }}" 80 | } 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /.swiftpm 4 | /Packages 5 | /*.xcodeproj 6 | xcuserdata/ 7 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - platform: ios 5 | scheme: IdentifiedCollections 6 | - platform: tvos 7 | scheme: IdentifiedCollections 8 | - platform: watchos 9 | scheme: IdentifiedCollections 10 | - documentation_targets: [IdentifiedCollections] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Point-Free, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test-all: test-linux test-swift 2 | 3 | test-linux: 4 | docker run \ 5 | --rm \ 6 | -v "$(PWD):$(PWD)" \ 7 | -w "$(PWD)" \ 8 | swift:5.7 \ 9 | bash -c 'apt-get update && apt-get -y install make && make test-swift' 10 | 11 | test-swift: 12 | swift test \ 13 | --parallel 14 | 15 | format: 16 | swift format --in-place --recursive . 17 | 18 | .PHONY: format test-all test-linux test-swift 19 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "38b71f86fd41a82358743f4f48fd130326986d1dd93baccd398525a4ac8196fd", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser", 8 | "state" : { 9 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 10 | "version" : "1.5.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-collections", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-collections", 17 | "state" : { 18 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 19 | "version" : "1.1.4" 20 | } 21 | }, 22 | { 23 | "identity" : "swift-collections-benchmark", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/apple/swift-collections-benchmark", 26 | "state" : { 27 | "revision" : "e8b88af0d678eacd65da84e99ccc1f0f402e9a97", 28 | "version" : "0.0.3" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-docc-plugin", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/apple/swift-docc-plugin", 35 | "state" : { 36 | "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", 37 | "version" : "1.4.3" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-docc-symbolkit", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/swiftlang/swift-docc-symbolkit", 44 | "state" : { 45 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 46 | "version" : "1.0.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-system", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-system", 53 | "state" : { 54 | "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", 55 | "version" : "1.4.0" 56 | } 57 | } 58 | ], 59 | "version" : 3 60 | } 61 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-identified-collections", 7 | products: [ 8 | .library( 9 | name: "IdentifiedCollections", 10 | targets: ["IdentifiedCollections"] 11 | ) 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-collections", from: "1.0.2"), 15 | .package(url: "https://github.com/apple/swift-collections-benchmark", from: "0.0.2"), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "IdentifiedCollections", 20 | dependencies: [ 21 | .product(name: "OrderedCollections", package: "swift-collections") 22 | ] 23 | ), 24 | .testTarget( 25 | name: "IdentifiedCollectionsTests", 26 | dependencies: ["IdentifiedCollections"] 27 | ), 28 | .executableTarget( 29 | name: "swift-identified-collections-benchmark", 30 | dependencies: [ 31 | "IdentifiedCollections", 32 | .product(name: "CollectionsBenchmark", package: "swift-collections-benchmark"), 33 | ] 34 | ), 35 | ] 36 | ) 37 | 38 | for target in package.targets { 39 | target.swiftSettings = target.swiftSettings ?? [] 40 | target.swiftSettings!.append(contentsOf: [ 41 | .enableExperimentalFeature("StrictConcurrency") 42 | ]) 43 | } 44 | 45 | #if !os(Windows) 46 | // DocC needs to be ported to Windows 47 | // https://github.com/thebrowsercompany/swift-build/issues/39 48 | package.dependencies.append( 49 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") 50 | ) 51 | #endif 52 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-identified-collections", 7 | products: [ 8 | .library( 9 | name: "IdentifiedCollections", 10 | targets: ["IdentifiedCollections"] 11 | ) 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-collections", from: "1.0.2"), 15 | .package(url: "https://github.com/apple/swift-collections-benchmark", from: "0.0.2"), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "IdentifiedCollections", 20 | dependencies: [ 21 | .product(name: "OrderedCollections", package: "swift-collections") 22 | ] 23 | ), 24 | .testTarget( 25 | name: "IdentifiedCollectionsTests", 26 | dependencies: ["IdentifiedCollections"] 27 | ), 28 | .executableTarget( 29 | name: "swift-identified-collections-benchmark", 30 | dependencies: [ 31 | "IdentifiedCollections", 32 | .product(name: "CollectionsBenchmark", package: "swift-collections-benchmark"), 33 | ] 34 | ), 35 | ], 36 | swiftLanguageModes: [.v6] 37 | ) 38 | 39 | #if !os(Windows) 40 | // DocC needs to be ported to Windows 41 | // https://github.com/thebrowsercompany/swift-build/issues/39 42 | package.dependencies.append( 43 | .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") 44 | ) 45 | #endif 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Identified Collections 2 | 3 | [![CI](https://github.com/pointfreeco/swift-identified-collections/workflows/CI/badge.svg)](https://actions-badge.atrox.dev/pointfreeco/swift-identified-collections/goto) 4 | [![Slack](https://img.shields.io/badge/slack-chat-informational.svg?label=Slack&logo=slack)](http://pointfree.co/slack-invite) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-identified-collections%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/pointfreeco/swift-identified-collections) 6 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpointfreeco%2Fswift-identified-collections%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/pointfreeco/swift-identified-collections) 7 | 8 | A library of data structures for working with collections of identifiable elements in an ergonomic, 9 | performant way. 10 | 11 | ## Motivation 12 | 13 | When modeling a collection of elements in your application's state, it is easy to reach for a 14 | standard `Array`. However, as your application becomes more complex, this approach can break down in 15 | many ways, including accidentally making mutations to the wrong elements or even crashing. 😬 16 | 17 | For example, if you were building a "Todos" application in SwiftUI, you might model an individual 18 | todo in an identifiable value type: 19 | 20 | ```swift 21 | struct Todo: Identifiable { 22 | var description = "" 23 | let id: UUID 24 | var isComplete = false 25 | } 26 | ``` 27 | 28 | And you would hold an array of these todos as a published field in your app's view model: 29 | 30 | ```swift 31 | class TodosViewModel: ObservableObject { 32 | @Published var todos: [Todo] = [] 33 | } 34 | ``` 35 | 36 | A view can render a list of these todos quite simply, and because they are identifiable we can even 37 | omit the `id` parameter of `List`: 38 | 39 | ```swift 40 | struct TodosView: View { 41 | @ObservedObject var viewModel: TodosViewModel 42 | 43 | var body: some View { 44 | List(self.viewModel.todos) { todo in 45 | ... 46 | } 47 | } 48 | } 49 | ``` 50 | 51 | If your deployment target is set to the latest version of SwiftUI, you may be tempted to pass along 52 | a binding to the list so that each row is given mutable access to its todo. This will work for 53 | simple cases, but as soon as you introduce side effects, like API clients or analytics, or want to 54 | write unit tests, you must push this logic into a view model, instead. And that means each row must 55 | be able to communicate its actions back to the view model. 56 | 57 | You could do so by introducing some endpoints on the view model, like when a row's completed toggle 58 | is changed: 59 | 60 | ```swift 61 | class TodosViewModel: ObservableObject { 62 | ... 63 | func todoCheckboxToggled(at id: Todo.ID) { 64 | guard let index = self.todos.firstIndex(where: { $0.id == id }) 65 | else { return } 66 | 67 | self.todos[index].isComplete.toggle() 68 | // TODO: Update todo on backend using an API client 69 | } 70 | } 71 | ``` 72 | 73 | This code is simple enough, but it can require a full traversal of the array to do its job. 74 | 75 | Perhaps it would be more performant for a row to communicate its index back to the view model 76 | instead, and then it could mutate the todo directly via its index subscript. But this makes the view 77 | more complicated: 78 | 79 | ```swift 80 | List(self.viewModel.todos.enumerated(), id: \.element.id) { index, todo in 81 | ... 82 | } 83 | ``` 84 | 85 | This isn't so bad, but at the moment it doesn't even compile. An 86 | [evolution proposal](https://github.com/apple/swift-evolution/blob/main/proposals/0312-indexed-and-enumerated-zip-collections.md) 87 | may change that soon, but in the meantime `List` and `ForEach` must be passed a 88 | `RandomAccessCollection`, which is perhaps most simply achieved by constructing another array: 89 | 90 | ```swift 91 | List(Array(self.viewModel.todos.enumerated()), id: \.element.id) { index, todo in 92 | ... 93 | } 94 | ``` 95 | 96 | This compiles, but we've just moved the performance problem to the view: every time this body is 97 | evaluated there's the possibility a whole new array is being allocated. 98 | 99 | But even if it were possible to pass an enumerated collection directly to these views, identifying 100 | an element of mutable state by an index introduces a number of other problems. 101 | 102 | While it's true that we can greatly simplify and improve the performance of any view model methods 103 | that mutate an element through its index subscript: 104 | 105 | ```swift 106 | class TodosViewModel: ObservableObject { 107 | ... 108 | func todoCheckboxToggled(at index: Int) { 109 | self.todos[index].isComplete.toggle() 110 | // TODO: Update todo on backend using an API client 111 | } 112 | } 113 | ``` 114 | 115 | Any asynchronous work that we add to this endpoint must take great care in _not_ using this index 116 | later on. An index is not a stable identifier: todos can be moved and removed at any time, and an 117 | index identifying "Buy lettuce" at one moment may identify "Call Mom" the next, or worse, may be a 118 | completely invalid index and crash your application! 119 | 120 | ```swift 121 | class TodosViewModel: ObservableObject { 122 | ... 123 | func todoCheckboxToggled(at index: Int) async { 124 | self.todos[index].isComplete.toggle() 125 | 126 | do { 127 | // ❌ Could update the wrong todo, or crash! 128 | self.todos[index] = try await self.apiClient.updateTodo(self.todos[index]) 129 | } catch { 130 | // Handle error 131 | } 132 | } 133 | } 134 | ``` 135 | 136 | Whenever you need to access a particular todo after performing some asynchronous work, you _must_ do 137 | the work of traversing the array: 138 | 139 | ```swift 140 | class TodosViewModel: ObservableObject { 141 | ... 142 | func todoCheckboxToggled(at index: Int) async { 143 | self.todos[index].isComplete.toggle() 144 | 145 | // 1️⃣ Get a reference to the todo's id before kicking off the async work 146 | let id = self.todos[index].id 147 | 148 | do { 149 | // 2️⃣ Update the todo on the backend 150 | let updatedTodo = try await self.apiClient.updateTodo(self.todos[index]) 151 | 152 | // 3️⃣ Find the updated index of the todo after the async work is done 153 | let updatedIndex = self.todos.firstIndex(where: { $0.id == id })! 154 | 155 | // 4️⃣ Update the correct todo 156 | self.todos[updatedIndex] = updatedTodo 157 | } catch { 158 | // Handle error 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | ## Introducing: identified collections 165 | 166 | Identified collections are designed to solve all of these problems by providing data structures for 167 | working with collections of identifiable elements in an ergonomic, performant way. 168 | 169 | Most of the time, you can simply swap an `Array` out for an `IdentifiedArray`: 170 | 171 | ```swift 172 | import IdentifiedCollections 173 | 174 | class TodosViewModel: ObservableObject { 175 | @Published var todos: IdentifiedArrayOf = [] 176 | ... 177 | } 178 | ``` 179 | 180 | And then you can mutate an element directly via its id-based subscript, no traversals needed, even 181 | after asynchronous work is performed: 182 | 183 | ```swift 184 | class TodosViewModel: ObservableObject { 185 | ... 186 | func todoCheckboxToggled(at id: Todo.ID) async { 187 | self.todos[id: id]?.isComplete.toggle() 188 | 189 | do { 190 | // 1️⃣ Update todo on backend and mutate it in the todos identified array. 191 | self.todos[id: id] = try await self.apiClient.updateTodo(self.todos[id: id]!) 192 | } catch { 193 | // Handle error 194 | } 195 | 196 | // No step 2️⃣ 😆 197 | } 198 | } 199 | ``` 200 | 201 | You can also simply pass the identified array to views like `List` and `ForEach` without any 202 | complications: 203 | 204 | ```swift 205 | List(self.viewModel.todos) { todo in 206 | ... 207 | } 208 | ``` 209 | 210 | Identified arrays are designed to integrate with SwiftUI applications, as well as applications 211 | written in 212 | [the Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture). 213 | 214 | ## Design 215 | 216 | `IdentifiedArray` is a lightweight wrapper around the 217 | [`OrderedDictionary`](https://github.com/apple/swift-collections/blob/main/Documentation/OrderedDictionary.md) 218 | type from Apple's [Swift Collections](https://github.com/apple/swift-collections). It shares many of 219 | the same performance characteristics and design considerations, but is better adapted to solving the 220 | problem of holding onto a collection of _identifiable_ elements in your application's state. 221 | 222 | `IdentifiedArray` does not expose any of the details of `OrderedDictionary` that may lead to 223 | breaking invariants. For example an `OrderedDictionary` may freely hold a value 224 | whose identifier does not match its key or multiple values could have the same id, and 225 | `IdentifiedArray` does not allow for these situations. 226 | 227 | And unlike 228 | [`OrderedSet`](https://github.com/apple/swift-collections/blob/main/Documentation/OrderedSet.md), 229 | `IdentifiedArray` does not require that its `Element` type conforms to the `Hashable` protocol, 230 | which may be difficult or impossible to do, and introduces questions around the quality of hashing, 231 | etc. 232 | 233 | `IdentifiedArray` does not even require that its `Element` conforms to `Identifiable`. Just as 234 | SwiftUI's `List` and `ForEach` views take an `id` key path to an element's identifier, 235 | `IdentifiedArray`s can be constructed with a key path: 236 | 237 | ```swift 238 | var numbers = IdentifiedArray(id: \Int.self) 239 | ``` 240 | 241 | ## Performance 242 | 243 | `IdentifiedArray` is designed to match the performance characteristics of `OrderedDictionary`. It 244 | has been benchmarked with 245 | [Swift Collections Benchmark](https://github.com/apple/swift-collections-benchmark): 246 | 247 | ![](.github/benchmark.png) 248 | 249 | ## Community 250 | 251 | If you want to discuss this library or have a question about how to use it to solve 252 | a particular problem, there are a number of places you can discuss with fellow 253 | [Point-Free](http://www.pointfree.co) enthusiasts: 254 | 255 | * For long-form discussions, we recommend the [discussions](http://github.com/pointfreeco/swift-identified-collections/discussions) tab of this repo. 256 | * For casual chat, we recommend the [Point-Free Community Slack](http://pointfree.co/slack-invite). 257 | 258 | ## Documentation 259 | 260 | The latest documentation for Identified Collections' APIs is available 261 | [here](https://swiftpackageindex.com/pointfreeco/swift-identified-collections/main/documentation/identifiedcollections). 262 | 263 | ## Translations 264 | 265 | - [Korean](https://gist.github.com/havilog/594bcaba48e7e40cb0395243fa96b47e) 266 | 267 | ## Interested in learning more? 268 | 269 | These concepts (and more) are explored thoroughly in [Point-Free](https://www.pointfree.co), a video 270 | series exploring functional programming and Swift hosted by [Brandon Williams](https://github.com/mbrandonw) and [Stephen Celis](https://github.com/stephencelis). 271 | 272 | Usage of `IdentifiedArray` in 273 | [the Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) was 274 | explored in the following [Point-Free](https://www.pointfree.co) episode: 275 | 276 | * [Episode 148](https://www.pointfree.co/episodes/ep148-derived-behavior-collections): Derived 277 | Behavior: Collections 278 | 279 | 280 | video poster image 281 | 282 | 283 | ## License 284 | 285 | All modules are released under the MIT license. See [LICENSE](LICENSE) for details. 286 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/Documentation.docc/IdentifiedCollections.md: -------------------------------------------------------------------------------- 1 | # ``IdentifiedCollections`` 2 | 3 | A library of data structures for working with collections of identifiable elements in an ergonomic, 4 | performant way. 5 | 6 | ## Overview 7 | 8 | ### Motivation 9 | 10 | When modeling a collection of elements in your application's state, it is easy to reach for a 11 | standard `Array`. However, as your application becomes more complex, this approach can break down in 12 | many ways, including accidentally making mutations to the wrong elements or even crashing. 😬 13 | 14 | For example, if you were building a "Todos" application in SwiftUI, you might model an individual 15 | todo in an identifiable value type: 16 | 17 | ```swift 18 | struct Todo: Identifiable { 19 | var description = "" 20 | let id: UUID 21 | var isComplete = false 22 | } 23 | ``` 24 | 25 | And you would hold an array of these todos as a published field in your app's view model: 26 | 27 | ```swift 28 | class TodosViewModel: ObservableObject { 29 | @Published var todos: [Todo] = [] 30 | } 31 | ``` 32 | 33 | A view can render a list of these todos quite simply, and because they are identifiable we can even 34 | omit the `id` parameter of `List`: 35 | 36 | ```swift 37 | struct TodosView: View { 38 | @ObservedObject var viewModel: TodosViewModel 39 | 40 | var body: some View { 41 | List(self.viewModel.todos) { todo in 42 | ... 43 | } 44 | } 45 | } 46 | ``` 47 | 48 | If your deployment target is set to the latest version of SwiftUI, you may be tempted to pass along 49 | a binding to the list so that each row is given mutable access to its todo. This will work for 50 | simple cases, but as soon as you introduce side effects, like API clients or analytics, or want to 51 | write unit tests, you must push this logic into a view model, instead. And that means each row must 52 | be able to communicate its actions back to the view model. 53 | 54 | You could do so by introducing some endpoints on the view model, like when a row's completed toggle 55 | is changed: 56 | 57 | ```swift 58 | class TodosViewModel: ObservableObject { 59 | ... 60 | func todoCheckboxToggled(at id: Todo.ID) { 61 | guard let index = self.todos.firstIndex(where: { $0.id == id }) 62 | else { return } 63 | 64 | self.todos[index].isComplete.toggle() 65 | // TODO: Update todo on backend using an API client 66 | } 67 | } 68 | ``` 69 | 70 | This code is simple enough, but it can require a full traversal of the array to do its job. 71 | 72 | Perhaps it would be more performant for a row to communicate its index back to the view model 73 | instead, and then it could mutate the todo directly via its index subscript. But this makes the view 74 | more complicated: 75 | 76 | ```swift 77 | List(self.viewModel.todos.enumerated(), id: \.element.id) { index, todo in 78 | ... 79 | } 80 | ``` 81 | 82 | This isn't so bad, but at the moment it doesn't even compile. An 83 | [evolution proposal](https://github.com/apple/swift-evolution/blob/main/proposals/0312-indexed-and-enumerated-zip-collections.md) 84 | may change that soon, but in the meantime `List` and `ForEach` must be passed a 85 | `RandomAccessCollection`, which is perhaps most simply achieved by constructing another array: 86 | 87 | ```swift 88 | List(Array(self.viewModel.todos.enumerated()), id: \.element.id) { index, todo in 89 | ... 90 | } 91 | ``` 92 | 93 | This compiles, but we've just moved the performance problem to the view: every time this body is 94 | evaluated there's the possibility a whole new array is being allocated. 95 | 96 | But even if it were possible to pass an enumerated collection directly to these views, identifying 97 | an element of mutable state by an index introduces a number of other problems. 98 | 99 | While it's true that we can greatly simplify and improve the performance of any view model methods 100 | that mutate an element through its index subscript: 101 | 102 | ```swift 103 | class TodosViewModel: ObservableObject { 104 | ... 105 | func todoCheckboxToggled(at index: Int) { 106 | self.todos[index].isComplete.toggle() 107 | // TODO: Update todo on backend using an API client 108 | } 109 | } 110 | ``` 111 | 112 | Any asynchronous work that we add to this endpoint must take great care in _not_ using this index 113 | later on. An index is not a stable identifier: todos can be moved and removed at any time, and an 114 | index identifying "Buy lettuce" at one moment may identify "Call Mom" the next, or worse, may be a 115 | completely invalid index and crash your application! 116 | 117 | ```swift 118 | class TodosViewModel: ObservableObject { 119 | ... 120 | func todoCheckboxToggled(at index: Int) async { 121 | self.todos[index].isComplete.toggle() 122 | 123 | do { 124 | // ❌ Could update the wrong todo, or crash! 125 | self.todos[index] = try await self.apiClient.updateTodo(self.todos[index]) 126 | } catch { 127 | // Handle error 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | Whenever you need to access a particular todo after performing some asynchronous work, you _must_ do 134 | the work of traversing the array: 135 | 136 | ```swift 137 | class TodosViewModel: ObservableObject { 138 | ... 139 | func todoCheckboxToggled(at index: Int) async { 140 | self.todos[index].isComplete.toggle() 141 | 142 | // 1️⃣ Get a reference to the todo's id before kicking off the async work 143 | let id = self.todos[index].id 144 | 145 | do { 146 | // 2️⃣ Update the todo on the backend 147 | let updatedTodo = try await self.apiClient.updateTodo(self.todos[index]) 148 | 149 | // 3️⃣ Find the updated index of the todo after the async work is done 150 | let updatedIndex = self.todos.firstIndex(where: { $0.id == id })! 151 | 152 | // 4️⃣ Update the correct todo 153 | self.todos[updatedIndex] = updatedTodo 154 | } catch { 155 | // Handle error 156 | } 157 | } 158 | } 159 | ``` 160 | 161 | ### Introducing: identified collections 162 | 163 | Identified collections are designed to solve all of these problems by providing data structures for 164 | working with collections of identifiable elements in an ergonomic, performant way. 165 | 166 | Most of the time, you can simply swap an `Array` out for an `IdentifiedArray`: 167 | 168 | ```swift 169 | import IdentifiedCollections 170 | 171 | class TodosViewModel: ObservableObject { 172 | @Published var todos: IdentifiedArrayOf = [] 173 | ... 174 | } 175 | ``` 176 | 177 | And then you can mutate an element directly via its id-based subscript, no traversals needed, even 178 | after asynchronous work is performed: 179 | 180 | ```swift 181 | class TodosViewModel: ObservableObject { 182 | ... 183 | func todoCheckboxToggled(at id: Todo.ID) async { 184 | self.todos[id: id]?.isComplete.toggle() 185 | 186 | do { 187 | // 1️⃣ Update todo on backend and mutate it in the todos identified array. 188 | self.todos[id: id] = try await self.apiClient.updateTodo(self.todos[id: id]!) 189 | } catch { 190 | // Handle error 191 | } 192 | 193 | // No step 2️⃣ 😆 194 | } 195 | } 196 | ``` 197 | 198 | You can also simply pass the identified array to views like `List` and `ForEach` without any 199 | complications: 200 | 201 | ```swift 202 | List(self.viewModel.todos) { todo in 203 | ... 204 | } 205 | ``` 206 | 207 | Identified arrays are designed to integrate with SwiftUI applications, as well as applications 208 | written in 209 | [the Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture). 210 | 211 | ### Design 212 | 213 | `IdentifiedArray` is a lightweight wrapper around the 214 | [`OrderedDictionary`](https://github.com/apple/swift-collections/blob/main/Documentation/OrderedDictionary.md) type from Apple's [Swift Collections](https://github.com/apple/swift-collections). 215 | It shares many of the same performance characteristics and design considerations, but is better 216 | adapted to solving the problem of holding onto a collection of _identifiable_ elements in your 217 | application's state. 218 | 219 | `IdentifiedArray` does not expose any of the details of `OrderedDictionary` that may lead to 220 | breaking invariants. For example an `OrderedDictionary` may freely hold a value 221 | whose identifier does not match its key or multiple values could have the same id, and 222 | `IdentifiedArray` does not allow for these situations. 223 | 224 | And unlike 225 | [`OrderedSet`](https://github.com/apple/swift-collections/blob/main/Documentation/OrderedSet.md), 226 | `IdentifiedArray` does not require that its `Element` type conforms to the `Hashable` protocol, 227 | which may be difficult or impossible to do, and introduces questions around the quality of hashing, 228 | etc. 229 | 230 | `IdentifiedArray` does not even require that its `Element` conforms to `Identifiable`. Just as 231 | SwiftUI's `List` and `ForEach` views take an `id` key path to an element's identifier, 232 | `IdentifiedArray`s can be constructed with a key path: 233 | 234 | ```swift 235 | var numbers = IdentifiedArray(id: \Int.self) 236 | ``` 237 | 238 | ### Performance 239 | 240 | `IdentifiedArray` is designed to match the performance characteristics of `OrderedDictionary`. 241 | It has been benchmarked with 242 | [Swift Collections Benchmark](https://github.com/apple/swift-collections-benchmark): 243 | 244 | ![](benchmark.png) 245 | 246 | ### Installation 247 | 248 | You can add Identified Collections to an Xcode project by adding it as a package dependency. 249 | 250 | [https://github.com/pointfreeco/swift-identified-collections](https://github.com/pointfreeco/swift-identified-collections) 251 | 252 | If you want to use Identified Collections in a [SwiftPM](https://swift.org/package-manager/) 253 | project, it's as simple as adding a `dependencies` clause to your `Package.swift`: 254 | 255 | ``` swift 256 | dependencies: [ 257 | .package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.5.0") 258 | ], 259 | ``` 260 | 261 | ## Topics 262 | 263 | ### Collections 264 | 265 | - ``IdentifiedArray`` 266 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/Documentation.docc/Resources/benchmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pointfreeco/swift-identified-collections/d9f9741112e86e6c62a376c76b4d4585b906c12d/Sources/IdentifiedCollections/Documentation.docc/Resources/benchmark.png -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/Identified/Identified.swift: -------------------------------------------------------------------------------- 1 | /// A wrapper around a value and a hashable identifier that conforms to identifiable. 2 | @dynamicMemberLookup 3 | public struct Identified: Identifiable { 4 | public let id: ID 5 | public var value: Value 6 | 7 | /// Initializes an identified value from a given value and a hashable identifier. 8 | /// 9 | /// - Parameters: 10 | /// - value: A value. 11 | /// - id: A hashable identifier. 12 | public init(_ value: Value, id: ID) { 13 | self.id = id 14 | self.value = value 15 | } 16 | 17 | /// Initializes an identified value from a given value and a function that can return a hashable 18 | /// identifier from the value. 19 | /// 20 | /// ```swift 21 | /// Identified(uuid, id: \.self) 22 | /// ``` 23 | /// 24 | /// - Parameters: 25 | /// - value: A value. 26 | /// - id: A hashable identifier. 27 | public init(_ value: Value, id: (Value) -> ID) { 28 | self.init(value, id: id(value)) 29 | } 30 | 31 | // NB: This overload works around a bug in key path function expressions and `\.self`. 32 | /// Initializes an identified value from a given value and a function that can return a hashable 33 | /// identifier from the value. 34 | /// 35 | /// ```swift 36 | /// Identified(uuid, id: \.self) 37 | /// ``` 38 | /// 39 | /// - Parameters: 40 | /// - value: A value. 41 | /// - id: A key path from the value to a hashable identifier. 42 | public init(_ value: Value, id: KeyPath) { 43 | self.init(value, id: value[keyPath: id]) 44 | } 45 | 46 | public subscript( 47 | dynamicMember keyPath: WritableKeyPath 48 | ) -> Subject { 49 | get { self.value[keyPath: keyPath] } 50 | set { self.value[keyPath: keyPath] = newValue } 51 | } 52 | 53 | public subscript( 54 | dynamicMember keyPath: KeyPath 55 | ) -> Subject { 56 | self.value[keyPath: keyPath] 57 | } 58 | } 59 | 60 | extension Identified: Decodable where ID: Decodable, Value: Decodable {} 61 | 62 | extension Identified: Encodable where ID: Encodable, Value: Encodable {} 63 | 64 | extension Identified: Equatable where Value: Equatable {} 65 | 66 | extension Identified: Hashable where Value: Hashable {} 67 | 68 | extension Identified: Sendable where ID: Sendable, Value: Sendable {} 69 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Codable.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | extension IdentifiedArray: Encodable where Element: Encodable { 4 | @inlinable 5 | public func encode(to encoder: Encoder) throws { 6 | var container = encoder.singleValueContainer() 7 | try container.encode(ContiguousArray(self._dictionary.values)) 8 | } 9 | } 10 | 11 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 12 | extension IdentifiedArray: Decodable 13 | where Element: Decodable & Identifiable, ID == Element.ID { 14 | @inlinable 15 | public init(from decoder: Decoder) throws { 16 | var container = try decoder.unkeyedContainer() 17 | self.init() 18 | while !container.isAtEnd { 19 | let element = try container.decode(Element.self) 20 | let (inserted, _) = self.append(element) 21 | guard inserted else { 22 | let context = DecodingError.Context( 23 | codingPath: container.codingPath, 24 | debugDescription: "Duplicate element at offset \(container.currentIndex - 1)" 25 | ) 26 | throw DecodingError.dataCorrupted(context) 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Collection.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | extension IdentifiedArray: Collection { 4 | @inlinable 5 | @inline(__always) 6 | public var startIndex: Int { self._dictionary.keys.startIndex } 7 | 8 | @inlinable 9 | @inline(__always) 10 | public var endIndex: Int { self._dictionary.keys.endIndex } 11 | 12 | @inlinable 13 | @inline(__always) 14 | public func index(after i: Int) -> Int { self._dictionary.keys.index(after: i) } 15 | 16 | /// Returns a new array containing the elements of the array that satisfy the given predicate. 17 | /// 18 | /// - Parameter isIncluded: A closure that takes an element as its argument and returns a Boolean 19 | /// value indicating whether it should be included in the returned array. 20 | /// - Returns: An array of the elements that `isIncluded` allows. 21 | /// - Complexity: O(`count`) 22 | @inlinable 23 | public func filter( 24 | _ isIncluded: (Element) throws -> Bool 25 | ) rethrows -> Self { 26 | try .init( 27 | id: self.id, 28 | _id: self._id, 29 | _dictionary: self._dictionary.filter { try isIncluded($1) } 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+CollectionAlgorithms.swift: -------------------------------------------------------------------------------- 1 | // Implementations in this file are from the SE-0270 preview package, with minimal changes to work 2 | // with `Foundation.IndexSet` and SwiftUI instead of the proposed `RangeSet` APIs: 3 | // 4 | // https://github.com/apple/swift-se0270-range-set 5 | 6 | //===----------------------------------------------------------*- swift -*-===// 7 | // 8 | // This source file is part of the Swift open source project 9 | // 10 | // Copyright (c) 2020 Apple Inc. and the Swift project authors 11 | // Licensed under Apache License v2.0 with Runtime Library Exception 12 | // 13 | // See https://swift.org/LICENSE.txt for license information 14 | // 15 | //===----------------------------------------------------------------------===// 16 | 17 | import Foundation 18 | 19 | extension IdentifiedArray { // : MutableCollection 20 | /// Moves all the elements at the specified offsets to the specified destination offset, 21 | /// preserving ordering. 22 | /// 23 | /// - Parameters: 24 | /// - source: The offsets of all elements to be moved. 25 | /// - destination: The destination offset. 26 | /// - Complexity: O(*n* log *n*), where *n* is the length of the collection. 27 | @inlinable 28 | public mutating func move(fromOffsets source: IndexSet, toOffset destination: Int) { 29 | let lowerCount = distance(from: self.startIndex, to: destination) 30 | let upperCount = distance(from: destination, to: self.endIndex) 31 | _ = self._indexedStablePartition( 32 | count: lowerCount, 33 | range: self.startIndex.., 106 | by belongsInSecondPartition: (Index) throws -> Bool 107 | ) rethrows -> Index { 108 | if n == 0 { return range.lowerBound } 109 | if n == 1 { 110 | return try belongsInSecondPartition(range.lowerBound) 111 | ? range.lowerBound 112 | : range.upperBound 113 | } 114 | let h = n / 2 115 | let i = index(range.lowerBound, offsetBy: h) 116 | let j = try self._indexedStablePartition( 117 | count: h, 118 | range: range.lowerBound.., 136 | shiftingToStart middle: Index 137 | ) -> Index { 138 | var m = middle 139 | var s = subrange.lowerBound 140 | let e = subrange.upperBound 141 | 142 | // Handle the trivial cases 143 | if s == m { return e } 144 | if m == e { return s } 145 | 146 | // We have two regions of possibly-unequal length that need to be 147 | // exchanged. The return value of this method is going to be the 148 | // position following that of the element that is currently last 149 | // (element j). 150 | // 151 | // [a b c d e f g|h i j] or [a b c|d e f g h i j] 152 | // ^ ^ ^ ^ ^ ^ 153 | // s m e s m e 154 | // 155 | var ret = e // start with a known incorrect result. 156 | while true { 157 | // Exchange the leading elements of each region (up to the 158 | // length of the shorter region). 159 | // 160 | // [a b c d e f g|h i j] or [a b c|d e f g h i j] 161 | // ^^^^^ ^^^^^ ^^^^^ ^^^^^ 162 | // [h i j d e f g|a b c] or [d e f|a b c g h i j] 163 | // ^ ^ ^ ^ ^ ^ ^ ^ 164 | // s s1 m m1/e s s1/m m1 e 165 | // 166 | let (s1, m1) = _swapNonemptySubrangePrefixes(s.., _ rhs: Range 218 | ) -> (Index, Index) { 219 | assert(!lhs.isEmpty) 220 | assert(!rhs.isEmpty) 221 | 222 | var p = lhs.lowerBound 223 | var q = rhs.lowerBound 224 | repeat { 225 | self.swapAt(p, q) 226 | self.formIndex(after: &p) 227 | self.formIndex(after: &q) 228 | } while p != lhs.upperBound && q != rhs.upperBound 229 | return (p, q) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+CustomDebugStringConvertible.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: CustomDebugStringConvertible { 2 | public var debugDescription: String { 3 | var result = "IdentifiedArray<\(Element.self)>([" 4 | var first = true 5 | for item in self { 6 | if first { 7 | first = false 8 | } else { 9 | result += ", " 10 | } 11 | debugPrint(item, terminator: "", to: &result) 12 | } 13 | result += "])" 14 | return result 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+CustomReflectable.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: CustomReflectable { 2 | public var customMirror: Mirror { 3 | Mirror(self, unlabeledChildren: Array(self), displayStyle: .collection) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+CustomStringConvertible.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: CustomStringConvertible { 2 | public var description: String { 3 | var result = "[" 4 | var first = true 5 | for item in self { 6 | if first { 7 | first = false 8 | } else { 9 | result += ", " 10 | } 11 | debugPrint(item, terminator: "", to: &result) 12 | } 13 | result += "]" 14 | return result 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Equatable.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: Equatable where Element: Equatable { 2 | @inlinable 3 | public static func == (lhs: Self, rhs: Self) -> Bool { 4 | lhs.elements == rhs.elements 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+ExpressibleByArrayLiteral.swift: -------------------------------------------------------------------------------- 1 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 2 | extension IdentifiedArray: ExpressibleByArrayLiteral where Element: Identifiable, ID == Element.ID { 3 | @inlinable 4 | public init(arrayLiteral elements: Element...) { 5 | self.init(uniqueElements: elements) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Hashable.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: Hashable where Element: Hashable { 2 | @inlinable 3 | public func hash(into hasher: inout Hasher) { 4 | hasher.combine(self.id) 5 | hasher.combine(self.count) 6 | for element in self { 7 | hasher.combine(element) 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+IdentifiedCollection.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | extension IdentifiedArray: _IdentifiedCollection { 4 | /// A read-only collection view for the ids contained in this array, as an `OrderedSet`. 5 | /// 6 | /// - Complexity: O(1) 7 | @inlinable 8 | @inline(__always) 9 | public var ids: OrderedSet { self._dictionary.keys } 10 | } 11 | 12 | extension IdentifiedArray: _MutableIdentifiedCollection { 13 | /// Accesses the value associated with the given id for reading and writing. 14 | /// 15 | /// This *id-based* subscript returns the element identified by the given id if found in the 16 | /// array, or `nil` if no element is found. 17 | /// 18 | /// When you assign an element for an id and that element already exists, the array overwrites the 19 | /// existing value in place. If the array doesn't contain the element, it is appended to the 20 | /// array. 21 | /// 22 | /// If you assign `nil` for a given id, the array removes the element identified by that id. 23 | /// 24 | /// - Parameter id: The id to find in the array. 25 | /// - Returns: The element associated with `id` if found in the array; otherwise, `nil`. 26 | /// - Complexity: Looking up values in the array through this subscript has an expected complexity 27 | /// of O(1) hashing/comparison operations on average, if `ID` implements high-quality hashing. 28 | /// Updating the array also has an amortized expected complexity of O(1) -- although individual 29 | /// updates may need to copy or resize the array's underlying storage. 30 | /// - Postcondition: Element identity must remain constant over modification. Modifying an 31 | /// element's id will cause a crash. 32 | @inlinable 33 | @inline(__always) 34 | public subscript(id id: ID) -> Element? { 35 | _read { yield self._dictionary[id] } 36 | _modify { 37 | yield &self._dictionary[id] 38 | precondition( 39 | self._dictionary[id].map { self._id($0) == id } ?? true, 40 | "Element identity must remain constant" 41 | ) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Initializers.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | extension IdentifiedArray { 4 | /// Creates a new array from the elements in the given sequence, which must not contain duplicate 5 | /// ids. 6 | /// 7 | /// In optimized builds, this initializer does not verify that the ids are actually unique. This 8 | /// makes creating the array somewhat faster if you know for sure that the elements are unique 9 | /// (e.g., because they come from another collection with guaranteed-unique members. However, if 10 | /// you accidentally call this initializer with duplicate members, it can return a corrupt array 11 | /// value that may be difficult to debug. 12 | /// 13 | /// - Parameters: 14 | /// - elements: A sequence of elements to use for the new array. Every element in `elements` 15 | /// must have a unique id. 16 | /// - id: The key path to an element's identifier. 17 | /// - Returns: A new array initialized with the elements of `elements`. 18 | /// - Precondition: The sequence must not have duplicate ids. 19 | /// - Complexity: Expected O(*n*) on average, where *n* is the count of elements, if `ID` 20 | /// implements high-quality hashing. 21 | @inlinable 22 | @_disfavoredOverload 23 | public init( 24 | uncheckedUniqueElements elements: some Sequence, 25 | id: KeyPath 26 | ) { 27 | self.init( 28 | id: id, 29 | _id: { $0[keyPath: id] }, 30 | _dictionary: .init(uncheckedUniqueKeysWithValues: elements.lazy.map { ($0[keyPath: id], $0) }) 31 | ) 32 | } 33 | 34 | /// Creates a new array from the elements in the given sequence. 35 | /// 36 | /// You use this initializer to create an array when you have a sequence of elements with unique 37 | /// ids. Passing a sequence with duplicate ids to this initializer results in a runtime error. 38 | /// 39 | /// - Parameters: 40 | /// - elements: A sequence of elements to use for the new array. Every element in `elements` 41 | /// must have a unique id. 42 | /// - id: The key path to an element's identifier. 43 | /// - Returns: A new array initialized with the elements of `elements`. 44 | /// - Precondition: The sequence must not have duplicate ids. 45 | /// - Complexity: Expected O(*n*) on average, where *n* is the count of elements, if `ID` 46 | /// implements high-quality hashing. 47 | @inlinable 48 | public init>( 49 | uniqueElements elements: S, 50 | id: KeyPath 51 | ) { 52 | if S.self == Self.self { 53 | self = elements as! Self 54 | return 55 | } 56 | if S.self == SubSequence.self { 57 | self.init(uncheckedUniqueElements: elements, id: id) 58 | return 59 | } 60 | self.init( 61 | id: id, 62 | _id: { $0[keyPath: id] }, 63 | _dictionary: .init(uniqueKeysWithValues: elements.lazy.map { ($0[keyPath: id], $0) }) 64 | ) 65 | } 66 | 67 | /// Creates a new array from the elements in the given sequence, using a combining closure to 68 | /// determine the element for any elements with duplicate identity. 69 | /// 70 | /// You use this initializer to create an array when you have an arbitrary sequence of elements 71 | /// that may not have unique ids. This initializer calls the `combine` closure with the current 72 | /// and new elements for any duplicate ids. Pass a closure as `combine` that returns the element 73 | /// to use in the resulting array: The closure can choose between the two elements, combine them 74 | /// to produce a new element, or even throw an error. 75 | /// 76 | /// - Parameters: 77 | /// - elements: A sequence of elements to use for the new array. 78 | /// - id: The key path to an element's identifier. 79 | /// - combine: Closure used to combine elements with duplicate ids. 80 | /// - Returns: A new array initialized with the unique elements of `elements`. 81 | /// - Complexity: Expected O(*n*) on average, where *n* is the count of elements, if `ID` 82 | /// implements high-quality hashing. 83 | public init( 84 | _ elements: some Sequence, 85 | id: KeyPath, 86 | uniquingIDsWith combine: (Element, Element) throws -> Element 87 | ) rethrows { 88 | try self.init( 89 | id: id, 90 | _id: { $0[keyPath: id] }, 91 | _dictionary: .init( 92 | elements.lazy.map { ($0[keyPath: id], $0) }, 93 | uniquingKeysWith: combine 94 | ) 95 | ) 96 | } 97 | 98 | /// Creates a new array from an existing array. This is functionally the same as copying the value 99 | /// of `elements` into a new variable. 100 | /// 101 | /// - Parameter elements: The elements to use as members of the new set. 102 | /// - Complexity: O(1) 103 | @inlinable 104 | public init(_ elements: Self) { 105 | self = elements 106 | } 107 | 108 | /// Creates a new set from an existing slice of another dictionary. 109 | /// 110 | /// - Parameter elements: The elements to use as members of the new array. 111 | /// - Complexity: This operation is expected to perform O(`elements.count`) operations on average, 112 | /// provided that `ID` implements high-quality hashing. 113 | @inlinable 114 | public init(_ elements: SubSequence) { 115 | self.init(uncheckedUniqueElements: elements, id: elements.base.id) 116 | } 117 | 118 | /// Creates an empty array. 119 | /// 120 | /// - Parameter id: The key path to an element's identifier. 121 | /// - Complexity: O(1) 122 | @inlinable 123 | public init(id: KeyPath) { 124 | self.init(id: id, _id: { $0[keyPath: id] }, _dictionary: .init()) 125 | } 126 | } 127 | 128 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 129 | extension IdentifiedArray where Element: Identifiable, ID == Element.ID { 130 | /// Creates a new array from the elements in the given sequence, which must not contain duplicate 131 | /// ids. 132 | /// 133 | /// In optimized builds, this initializer does not verify that the ids are actually unique. This 134 | /// makes creating the array somewhat faster if you know for sure that the elements are unique 135 | /// (e.g., because they come from another collection with guaranteed-unique members. However, if 136 | /// you accidentally call this initializer with duplicate members, it can return a corrupt array 137 | /// value that may be difficult to debug. 138 | /// 139 | /// - Parameter elements: A sequence of elements to use for the new array. Every element in 140 | /// `elements` must have a unique id. 141 | /// - Returns: A new array initialized with the elements of `elements`. 142 | /// - Precondition: The sequence must not have duplicate ids. 143 | /// - Complexity: Expected O(*n*) on average, where *n* is the count of elements, if `ID` 144 | /// implements high-quality hashing. 145 | @inlinable 146 | @_disfavoredOverload 147 | public init(uncheckedUniqueElements elements: some Sequence) { 148 | self.init( 149 | id: \.id, 150 | _id: { $0.id }, 151 | _dictionary: .init(uncheckedUniqueKeysWithValues: elements.lazy.map { ($0.id, $0) }) 152 | ) 153 | } 154 | 155 | /// Creates a new array from the elements in the given sequence. 156 | /// 157 | /// You use this initializer to create an array when you have a sequence of elements with unique 158 | /// ids. Passing a sequence with duplicate ids to this initializer results in a runtime error. 159 | /// 160 | /// - Parameter elements: A sequence of elements to use for the new array. Every element in 161 | /// `elements` must have a unique id. 162 | /// - Returns: A new array initialized with the elements of `elements`. 163 | /// - Precondition: The sequence must not have duplicate ids. 164 | /// - Complexity: Expected O(*n*) on average, where *n* is the count of elements, if `ID` 165 | /// implements high-quality hashing. 166 | @inlinable 167 | public init>(uniqueElements elements: S) { 168 | if S.self == Self.self { 169 | self = elements as! Self 170 | return 171 | } 172 | if let elements = elements as? SubSequence { 173 | self.init(uncheckedUniqueElements: elements, id: elements.base.id) 174 | return 175 | } 176 | self.init( 177 | id: \.id, 178 | _id: { $0.id }, 179 | _dictionary: .init(uniqueKeysWithValues: elements.lazy.map { ($0.id, $0) }) 180 | ) 181 | } 182 | 183 | /// Creates a new array from the elements in the given sequence, using a combining closure to 184 | /// determine the element for any elements with duplicate ids. 185 | /// 186 | /// You use this initializer to create an array when you have an arbitrary sequence of elements 187 | /// that may not have unique ids. This initializer calls the `combine` closure with the current 188 | /// and new elements for any duplicate ids. Pass a closure as `combine` that returns the element 189 | /// to use in the resulting array: The closure can choose between the two elements, combine them 190 | /// to produce a new element, or even throw an error. 191 | /// 192 | /// - Parameters: 193 | /// - elements: A sequence of elements to use for the new array. 194 | /// - combine: Closure used to combine duplicated elements. 195 | /// - Returns: A new array initialized with the unique elements of `elements`. 196 | /// - Complexity: Expected O(*n*) on average, where *n* is the count of elements, if `ID` 197 | /// implements high-quality hashing. 198 | @inlinable 199 | public init( 200 | _ elements: some Sequence, 201 | uniquingIDsWith combine: (Element, Element) throws -> Element 202 | ) rethrows { 203 | try self.init( 204 | id: \.id, 205 | _id: { $0.id }, 206 | _dictionary: .init( 207 | elements.lazy.map { ($0.id, $0) }, 208 | uniquingKeysWith: combine 209 | ) 210 | ) 211 | } 212 | } 213 | 214 | // MARK: - Deprecations 215 | 216 | extension IdentifiedArray { 217 | @available(*, deprecated, renamed: "init(uniqueElements:id:)") 218 | public init(_ elements: some Sequence, id: KeyPath) { 219 | self.init(uniqueElements: elements, id: id) 220 | } 221 | } 222 | 223 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 224 | extension IdentifiedArray where Element: Identifiable, ID == Element.ID { 225 | @available(*, deprecated, renamed: "init(uniqueElements:)") 226 | public init(_ elements: some Sequence) { 227 | self.init(uniqueElements: elements) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Insertions.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | extension IdentifiedArray { 4 | /// Append a new member to the end of the array, if the array doesn't already contain it. 5 | /// 6 | /// - Parameter item: The element to add to the array. 7 | /// - Returns: A pair `(inserted, index)`, where `inserted` is a Boolean value indicating whether 8 | /// the operation added a new element, and `index` is the index of `item` in the resulting 9 | /// array. 10 | /// - Complexity: The operation is expected to perform O(1) copy, hash, and compare operations on 11 | /// the `ID` type, if it implements high-quality hashing. 12 | @inlinable 13 | @inline(__always) 14 | @discardableResult 15 | public mutating func append(_ item: Element) -> (inserted: Bool, index: Int) { 16 | self.insert(item, at: self.endIndex) 17 | } 18 | 19 | /// Append the contents of a sequence to the end of the set, excluding elements that are already 20 | /// members. 21 | /// 22 | /// - Parameter elements: A finite sequence of elements to append. 23 | /// - Complexity: The operation is expected to perform amortized O(1) copy, hash, and compare 24 | /// operations on the `Element` type, if it implements high-quality hashing. 25 | @inlinable 26 | public mutating func append(contentsOf newElements: some Sequence) { 27 | self.reserveCapacity(self.count + newElements.underestimatedCount) 28 | for element in newElements { 29 | self.append(element) 30 | } 31 | } 32 | 33 | /// Insert a new member to this array at the specified index, if the array doesn't already contain 34 | /// it. 35 | /// 36 | /// - Parameter item: The element to insert. 37 | /// - Returns: A pair `(inserted, index)`, where `inserted` is a Boolean value indicating whether 38 | /// the operation added a new element, and `index` is the index of `item` in the resulting 39 | /// array. If `inserted` is true, then the returned `index` may be different from the index 40 | /// requested. 41 | /// 42 | /// - Complexity: The operation is expected to perform amortized O(`self.count`) copy, hash, and 43 | /// compare operations on the `ID` type, if it implements high-quality hashing. (Insertions need 44 | /// to make room in the storage array to add the inserted element.) 45 | @inlinable 46 | @discardableResult 47 | public mutating func insert(_ item: Element, at i: Int) -> (inserted: Bool, index: Int) { 48 | if let existing = self._dictionary.index(forKey: _id(item)) { 49 | return (false, existing) 50 | } 51 | self._dictionary.updateValue(item, forKey: _id(item), insertingAt: i) 52 | return (true, i) 53 | } 54 | 55 | /// Replace the member at the given index with a new value of the same identity. 56 | /// 57 | /// - Parameter item: The new value that should replace the original element. `item` must match 58 | /// the identity of the original value. 59 | /// - Parameter index: The index of the element to be replaced. 60 | /// - Returns: The original element that was replaced. 61 | /// - Complexity: Amortized O(1). 62 | @inlinable 63 | @discardableResult 64 | public mutating func update(_ item: Element, at i: Int) -> Element { 65 | let old = self._dictionary.elements[i].key 66 | precondition( 67 | _id(item) == old, "The replacement item must match the identity of the original" 68 | ) 69 | return self._dictionary.updateValue(item, forKey: old)! 70 | } 71 | 72 | /// Adds the given element to the array unconditionally, either appending it to the array, or 73 | /// replacing an existing value if it's already present. 74 | /// 75 | /// - Parameter item: The value to append or replace. 76 | /// - Returns: The original element that was replaced by this operation, or `nil` if the value was 77 | /// appended to the end of the collection. 78 | /// - Complexity: The operation is expected to perform amortized O(1) copy, hash, and compare 79 | /// operations on the `ID` type, if it implements high-quality hashing. 80 | @inlinable 81 | @discardableResult 82 | public mutating func updateOrAppend(_ item: Element) -> Element? { 83 | self._dictionary.updateValue(item, forKey: _id(item)) 84 | } 85 | 86 | /// Adds the given element into the set unconditionally, either inserting it at the specified 87 | /// index, or replacing an existing value if it's already present. 88 | /// 89 | /// - Parameter item: The value to append or replace. 90 | /// - Parameter index: The index at which to insert the new member if `item` isn't already in the 91 | /// set. 92 | /// - Returns: The original element that was replaced by this operation, or `nil` if the value was 93 | /// newly inserted into the collection. 94 | /// - Complexity: The operation is expected to perform amortized O(1) copy, hash, and compare 95 | /// operations on the `ID` type, if it implements high-quality hashing. 96 | @inlinable 97 | @discardableResult 98 | public mutating func updateOrInsert( 99 | _ item: Element, 100 | at i: Int 101 | ) -> (originalMember: Element?, index: Int) { 102 | self._dictionary.updateValue(item, forKey: _id(item), insertingAt: i) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+MutableCollection.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | extension IdentifiedArray: MutableCollection { 4 | @inlinable 5 | @inline(__always) 6 | public subscript(position: Int) -> Element { 7 | _read { yield self._dictionary.elements.values[position] } 8 | set { 9 | let key = _id(newValue) 10 | if let index = self._dictionary.keys.firstIndex(of: key) { 11 | self._dictionary.swapAt(index, position) 12 | self._dictionary.updateValue(newValue, forKey: key) 13 | } else { 14 | self._dictionary.remove(at: position) 15 | self._dictionary.updateValue(newValue, forKey: key, insertingAt: position) 16 | } 17 | } 18 | _modify { 19 | yield &self._dictionary.elements.values[position] 20 | precondition( 21 | self._dictionary.elements.keys[position] == _id(self._dictionary.elements.values[position]), 22 | "Element identity must remain constant" 23 | ) 24 | } 25 | } 26 | 27 | /// Reorders the elements of the array such that all the elements that match the given predicate 28 | /// are after all the elements that don't match. 29 | /// 30 | /// After partitioning a collection, there is a pivot index `p` where no element before `p` 31 | /// satisfies the `belongsInSecondPartition` predicate and every element at or after `p` satisfies 32 | /// `belongsInSecondPartition`. 33 | /// 34 | /// - Parameter belongsInSecondPartition: A predicate used to partition the collection. All 35 | /// elements satisfying this predicate are ordered after all elements not satisfying it. 36 | /// - Returns: The index of the first element in the reordered collection that matches 37 | /// `belongsInSecondPartition`. If no elements in the collection match 38 | /// `belongsInSecondPartition`, the returned index is equal to the collection's `endIndex`. 39 | /// - Complexity: O(`count`) 40 | @inlinable 41 | public mutating func partition( 42 | by belongsInSecondPartition: (Element) throws -> Bool 43 | ) rethrows -> Int { 44 | try self._dictionary.partition { (_, value) in 45 | try belongsInSecondPartition(value) 46 | } 47 | } 48 | 49 | /// Reverses the elements of the array in place. 50 | /// 51 | /// - Complexity: O(`count`) 52 | @inlinable 53 | public mutating func reverse() { 54 | self._dictionary.reverse() 55 | } 56 | 57 | /// Shuffles the collection in place. 58 | /// 59 | /// Use the `shuffle()` method to randomly reorder the elements of an array. 60 | /// 61 | /// This method is equivalent to calling ``shuffle(using:)``, passing in the system's default 62 | /// random generator. 63 | /// 64 | /// - Complexity: O(*n*), where *n* is the length of the collection. 65 | @inlinable 66 | public mutating func shuffle() { 67 | self._dictionary.shuffle() 68 | } 69 | 70 | /// Shuffles the collection in place, using the given generator as a source for randomness. 71 | /// 72 | /// You use this method to randomize the elements of a collection when you are using a custom 73 | /// random number generator. 74 | /// 75 | /// - Parameter generator: The random number generator to use when shuffling the collection. 76 | /// - Complexity: O(*n*), where *n* is the length of the collection. 77 | /// - Note: The algorithm used to shuffle a collection may change in a future version of Swift. 78 | /// If you're passing a generator that results in the same shuffled order each time you run your 79 | /// program, that sequence may change when your program is compiled using a different version of 80 | /// Swift. 81 | @inlinable 82 | public mutating func shuffle(using generator: inout T) { 83 | self._dictionary.shuffle(using: &generator) 84 | } 85 | 86 | /// Sorts the collection in place, using the given predicate as the comparison between elements. 87 | /// 88 | /// When you want to sort a collection of elements that don't conform to the `Comparable` 89 | /// protocol, pass a closure to this method that returns `true` when the first element should be 90 | /// ordered before the second. 91 | /// 92 | /// Alternatively, use this method to sort a collection of elements that do conform to 93 | /// `Comparable` when you want the sort to be descending instead of ascending. Pass the 94 | /// greater-than operator (`>`) operator as the predicate. 95 | /// 96 | /// `areInIncreasingOrder` must be a *strict weak ordering* over the elements. That is, for any 97 | /// elements `a`, `b`, and `c`, the following conditions must hold: 98 | /// 99 | /// * `areInIncreasingOrder(a, a)` is always `false`. (Irreflexivity) 100 | /// * If `areInIncreasingOrder(a, b)` and `areInIncreasingOrder(b, c)` are both `true`, then 101 | /// `areInIncreasingOrder(a, c)` is also `true`. (Transitive comparability) 102 | /// * Two elements are *incomparable* if neither is ordered before the other according to the 103 | /// predicate. If `a` and `b` are incomparable, and `b` and `c` are incomparable, then `a` 104 | /// and `c` are also incomparable. (Transitive incomparability) 105 | /// 106 | /// The sorting algorithm is not guaranteed to be stable. A stable sort preserves the relative 107 | /// order of elements for which `areInIncreasingOrder` does not establish an order. 108 | /// 109 | /// - Parameter areInIncreasingOrder: A predicate that returns `true` if its first argument should 110 | /// be ordered before its second argument; otherwise, `false`. If `areInIncreasingOrder` throws 111 | /// an error during the sort, the elements may be in a different order, but none will be lost. 112 | /// - Complexity: O(*n* log *n*), where *n* is the length of the collection. 113 | @inlinable 114 | public mutating func sort( 115 | by areInIncreasingOrder: (Element, Element) throws -> Bool 116 | ) rethrows { 117 | try self._dictionary.sort(by: { try areInIncreasingOrder($0.value, $1.value) }) 118 | } 119 | 120 | /// Returns a copy of the collection, sorted using the given predicate as 121 | /// the comparison between elements. 122 | /// 123 | /// When you want to sort a collection of elements that don't conform to the `Comparable` 124 | /// protocol, pass a closure to this method that returns `true` when the first element should be 125 | /// ordered before the second. 126 | /// 127 | /// Alternatively, use this method to sort a collection of elements that do conform to 128 | /// `Comparable` when you want the sort to be descending instead of ascending. Pass the 129 | /// greater-than operator (`>`) operator as the predicate. 130 | /// 131 | /// `areInIncreasingOrder` must be a *strict weak ordering* over the elements. That is, for any 132 | /// elements `a`, `b`, and `c`, the following conditions must hold: 133 | /// 134 | /// * `areInIncreasingOrder(a, a)` is always `false`. (Irreflexivity) 135 | /// * If `areInIncreasingOrder(a, b)` and `areInIncreasingOrder(b, c)` are both `true`, then 136 | /// `areInIncreasingOrder(a, c)` is also `true`. (Transitive comparability) 137 | /// * Two elements are *incomparable* if neither is ordered before the other according to the 138 | /// predicate. If `a` and `b` are incomparable, and `b` and `c` are incomparable, then `a` 139 | /// and `c` are also incomparable. (Transitive incomparability) 140 | /// 141 | /// The sorting algorithm is not guaranteed to be stable. A stable sort preserves the relative 142 | /// order of elements for which `areInIncreasingOrder` does not establish an order. 143 | /// 144 | /// - Parameter areInIncreasingOrder: A predicate that returns `true` if its first argument should 145 | /// be ordered before its second argument; otherwise, `false`. If `areInIncreasingOrder` throws 146 | /// an error during the sort, the elements may be in a different order, but none will be lost. 147 | /// - Complexity: O(*n* log *n*), where *n* is the length of the collection. 148 | /// - Returns: A sorted copy of this collection. 149 | @inlinable 150 | public func sorted( 151 | by areInIncreasingOrder: (Element, Element) throws -> Bool 152 | ) rethrows -> Self { 153 | var copy = self 154 | try copy.sort(by: areInIncreasingOrder) 155 | return copy 156 | } 157 | 158 | /// Exchanges the values at the specified indices of the array. 159 | /// 160 | /// Both parameters must be valid indices below ``endIndex``. Passing the same index as both `i` 161 | /// and `j` has no effect. 162 | /// 163 | /// - Parameters: 164 | /// - i: The index of the first value to swap. 165 | /// - j: The index of the second value to swap. 166 | /// - Complexity: O(1) when the array's storage isn't shared with another value; O(`count`) 167 | /// otherwise. 168 | @inlinable 169 | public mutating func swapAt(_ i: Int, _ j: Int) { 170 | self._dictionary.swapAt(i, j) 171 | } 172 | } 173 | 174 | extension IdentifiedArray where Element: Comparable { 175 | /// Sorts the set in place. 176 | /// 177 | /// You can sort an ordered set of elements that conform to the `Comparable` protocol by calling 178 | /// this method. Elements are sorted in ascending order. 179 | /// 180 | /// To sort the elements of your collection in descending order, pass the greater-than operator 181 | /// (`>`) to the ``sort(by:)`` method. 182 | /// 183 | /// The sorting algorithm is not guaranteed to be stable. A stable sort preserves the relative 184 | /// order of elements that compare equal. 185 | /// 186 | /// - Complexity: O(*n* log *n*), where *n* is the length of the collection. 187 | @inlinable 188 | public mutating func sort() { 189 | self.sort(by: <) 190 | } 191 | 192 | /// Returns a sorted copy of the collection. 193 | /// 194 | /// You can sort an ordered set of elements that conform to the `Comparable` protocol by calling 195 | /// this method. Elements are sorted in ascending order. 196 | /// 197 | /// To sort the elements of your collection in descending order, pass the greater-than operator 198 | /// (`>`) to the ``sort(by:)`` method. 199 | /// 200 | /// The sorting algorithm is not guaranteed to be stable. A stable sort preserves the relative 201 | /// order of elements that compare equal. 202 | /// 203 | /// - Complexity: O(*n* log *n*), where *n* is the length of the collection. 204 | /// - Returns: A sorted copy of this collection. 205 | @inlinable 206 | public func sorted() -> Self { 207 | self.sorted(by: <) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+RandomAccessCollection.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: RandomAccessCollection {} 2 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+RangeReplaceableCollection.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 4 | extension IdentifiedArray: RangeReplaceableCollection 5 | where Element: Identifiable, ID == Element.ID { 6 | /// Creates an empty array. 7 | /// 8 | /// This initializer is equivalent to initializing with an empty array literal. 9 | /// 10 | /// - Complexity: O(1) 11 | @inlinable 12 | public init() { 13 | self.init(id: \.id, _id: { $0.id }, _dictionary: .init()) 14 | } 15 | 16 | @inlinable 17 | public mutating func replaceSubrange( 18 | _ subrange: Range, with newElements: some Collection 19 | ) { 20 | self._dictionary.removeSubrange(subrange) 21 | self._dictionary.reserveCapacity(self.count + newElements.count) 22 | for element in newElements.reversed() { 23 | self._dictionary.updateValue( 24 | element, 25 | forKey: self._id(element), 26 | insertingAt: subrange.startIndex 27 | ) 28 | } 29 | } 30 | } 31 | 32 | extension IdentifiedArray { 33 | /// Removes and returns the element at the specified position. 34 | /// 35 | /// All the elements following the specified position are moved to close the resulting gap. 36 | /// 37 | /// - Parameter index: The position of the element to remove. 38 | /// - Returns: The removed element. 39 | /// - Precondition: `index` must be a valid index of the collection that is not equal to the 40 | /// collection's end index. 41 | /// - Complexity: O(`count`) 42 | @inlinable 43 | @discardableResult 44 | public mutating func remove(at index: Int) -> Element { 45 | self._dictionary.remove(at: index).value 46 | } 47 | 48 | /// Removes all members from the set. 49 | /// 50 | /// - Parameter keepingCapacity: If `true`, the array's storage capacity is preserved; if `false`, 51 | /// the underlying storage is released. The default is `false`. 52 | /// - Complexity: O(`count`) 53 | @inlinable 54 | public mutating func removeAll(keepingCapacity keepCapacity: Bool = false) { 55 | self._dictionary.removeAll(keepingCapacity: keepCapacity) 56 | } 57 | 58 | /// Removes all the elements that satisfy the given predicate. 59 | /// 60 | /// Use this method to remove every element in a collection that meets particular criteria. The 61 | /// order of the remaining elements is preserved. 62 | /// 63 | /// - Parameter shouldBeRemoved: A closure that takes an element of the collection as its argument 64 | /// and returns a Boolean value indicating whether the element should be removed from the 65 | /// collection. 66 | /// - Complexity: O(`count`) 67 | @inlinable 68 | public mutating func removeAll( 69 | where shouldBeRemoved: (Element) throws -> Bool 70 | ) rethrows { 71 | try self._dictionary.removeAll(where: { try shouldBeRemoved($0.value) }) 72 | } 73 | 74 | /// Removes the first element of a non-empty array. 75 | /// 76 | /// The members following the removed item need to be moved to close the resulting gap in the 77 | /// storage array. 78 | /// 79 | /// - Returns: The removed element. 80 | /// - Precondition: The array must be non-empty. 81 | /// - Complexity: O(`count`). 82 | @inlinable 83 | @discardableResult 84 | public mutating func removeFirst() -> Element { 85 | self._dictionary.removeFirst().value 86 | } 87 | 88 | /// Removes the first `n` elements of the collection. 89 | /// 90 | /// The members following the removed items need to be moved to close the resulting gap in the 91 | /// storage array. 92 | /// 93 | /// - Parameter n: The number of elements to remove from the collection. 94 | /// - Precondition: `n` must be greater than or equal to zero and must not exceed the number of 95 | /// elements in the collection. 96 | /// - Complexity: O(`count`). 97 | @inlinable 98 | public mutating func removeFirst(_ n: Int) { 99 | self._dictionary.removeFirst(n) 100 | } 101 | 102 | /// Removes the last element of a non-empty array. 103 | /// 104 | /// - Returns: The removed element. 105 | /// - Precondition: The array must be non-empty. 106 | /// - Complexity: Expected to be O(`1`) on average, if `ID` implements high-quality hashing. 107 | @inlinable 108 | @discardableResult 109 | public mutating func removeLast() -> Element { 110 | self._dictionary.removeLast().value 111 | } 112 | 113 | /// Removes the last `n` element of the set. 114 | /// 115 | /// - Parameter n: The number of elements to remove from the collection. 116 | /// - Precondition: `n` must be greater than or equal to zero and must not exceed the number of 117 | /// elements in the collection. 118 | /// - Complexity: Expected to be O(`n`) on average, if `ID` implements high-quality hashing. 119 | @inlinable 120 | public mutating func removeLast(_ n: Int) { 121 | self._dictionary.removeLast(n) 122 | } 123 | 124 | /// Removes the specified subrange of elements from the collection. 125 | /// 126 | /// All the elements following the specified subrange are moved to close the resulting gap. 127 | /// 128 | /// - Parameter bounds: The subrange of the collection to remove. 129 | /// - Precondition: The bounds of the range must be valid indices of the collection. 130 | /// - Complexity: O(`count`) 131 | @inlinable 132 | public mutating func removeSubrange(_ bounds: Range) { 133 | self._dictionary.removeSubrange(bounds) 134 | } 135 | 136 | /// Removes the specified subrange of elements from the collection. 137 | /// 138 | /// All the elements following the specified subrange are moved to close the resulting gap. 139 | /// 140 | /// - Parameter bounds: The subrange of the collection to remove. 141 | /// - Precondition: The bounds of the range must be valid indices of the collection. 142 | /// - Complexity: O(`count`) 143 | @inlinable 144 | public mutating func removeSubrange(_ bounds: R) 145 | where R: RangeExpression, R.Bound == Int { 146 | self._dictionary.removeSubrange(bounds) 147 | } 148 | 149 | /// Reserves enough space to store the specified number of elements. 150 | /// 151 | /// This method ensures that the array has unique, mutable, contiguous storage, with space 152 | /// allocated for at least the requested number of elements. 153 | /// 154 | /// If you are adding a known number of elements to a dictionary, call this method once before 155 | /// the first insertion to avoid multiple reallocations. 156 | /// 157 | /// Do not call this method in a loop -- it does not use an exponential allocation strategy, so 158 | /// doing that can result in quadratic instead of linear performance. 159 | /// 160 | /// - Parameter minimumCapacity: The minimum number of elements that the array should be able to 161 | /// store without reallocating its storage. 162 | /// - Complexity: O(`max(count, minimumCapacity)`) 163 | @inlinable 164 | public mutating func reserveCapacity(_ minimumCapacity: Int) { 165 | self._dictionary.reserveCapacity(minimumCapacity) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray+Sendable.swift: -------------------------------------------------------------------------------- 1 | extension IdentifiedArray: @unchecked Sendable 2 | where ID: Sendable, Element: Sendable {} 3 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedArray/IdentifiedArray.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | /// An ordered collection of identifiable elements. 4 | /// 5 | /// Similar to the standard `Array`, identified arrays maintain their elements in a particular 6 | /// user-specified order, and they support efficient random access traversal of their members. 7 | /// However, unlike `Array`, identified arrays introduce the ability to uniquely identify elements, 8 | /// using a hash table to ensure that no two elements have the same identity, and to efficiently 9 | /// look up elements corresponding to specific identifiers. 10 | /// 11 | /// ``IdentifiedArray`` is a useful alternative to `Array` when you need to be able to efficiently 12 | /// access unique elements by a stable identifier. It is also a useful alternative to `OrderedSet`, 13 | /// where the `Hashable` requirement may be too strict. 14 | /// 15 | /// You can create an identified array with any element type that conforms to the `Identifiable` 16 | /// protocol. 17 | /// 18 | /// ```swift 19 | /// struct User: Identifiable { var id: String } 20 | /// var users: IdentifiedArray = [User(id: "u_42"), User(id: "u_1729")] 21 | /// ``` 22 | /// 23 | /// Or you can provide a key path that describes an element's identity: 24 | /// 25 | /// ```swift 26 | /// var numbers = IdentifiedArray(id: \Int.self) 27 | /// ``` 28 | /// 29 | /// # Motivation 30 | /// 31 | /// When modeling a collection of elements in your application's state, it is easy to reach for a 32 | /// standard `Array`. However, as your application becomes more complex, this approach can break 33 | /// down in many ways, including accidentally making mutations to the wrong elements or even 34 | /// crashing. 😬 35 | /// 36 | /// For example, if you were building a "Todos" application in SwiftUI, you might model an 37 | /// individual todo in an identifiable value type: 38 | /// 39 | /// ```swift 40 | /// struct Todo: Identifiable { 41 | /// var description = "" 42 | /// let id: UUID 43 | /// var isComplete = false 44 | /// } 45 | /// ``` 46 | /// 47 | /// And you would hold an array of these todos as a published field in your app's view model: 48 | /// 49 | /// ```swift 50 | /// class TodosViewModel: ObservableObject { 51 | /// @Published var todos: [Todo] = [] 52 | /// } 53 | /// ``` 54 | /// 55 | /// A view can render a list of these todos quite simply, and because they are identifiable we can 56 | /// even omit the `id` parameter of `List`: 57 | /// 58 | /// ```swift 59 | /// struct TodosView: View { 60 | /// @ObservedObject var viewModel: TodosViewModel 61 | /// 62 | /// var body: some View { 63 | /// List(self.viewModel.todos) { todo in 64 | /// ... 65 | /// } 66 | /// } 67 | /// } 68 | /// ``` 69 | /// 70 | /// If your deployment target is set to the latest version of SwiftUI, you may be tempted to pass 71 | /// along a binding to the list so that each row is given mutable access to its todo. This will work 72 | /// for simple cases, but as soon as you introduce side effects, like API clients or analytics, or 73 | /// want to write unit tests, you must push this logic into a view model, instead. And that means 74 | /// each row must be able to communicate its actions back to the view model. 75 | /// 76 | /// You could do so by introducing some endpoints on the view model, like when a row's completed 77 | /// toggle is changed: 78 | /// 79 | /// ```swift 80 | /// class TodosViewModel: ObservableObject { 81 | /// ... 82 | /// func todoCheckboxToggled(at id: Todo.ID) { 83 | /// guard let index = self.todos.firstIndex(where: { $0.id == id }) 84 | /// else { return } 85 | /// 86 | /// self.todos[index].isComplete.toggle() 87 | /// // TODO: Update todo on backend using an API client 88 | /// } 89 | /// } 90 | /// ``` 91 | /// 92 | /// This code is simple enough, but it can require a full traversal of the array to do its job. 93 | /// 94 | /// Perhaps it would be more performant for a row to communicate its index back to the view model 95 | /// instead, and then it could mutate the todo directly via its index subscript. But this makes the 96 | /// view more complicated: 97 | /// 98 | /// ```swift 99 | /// List(self.viewModel.todos.enumerated(), id: \.element.id) { index, todo in 100 | /// ... 101 | /// } 102 | /// ``` 103 | /// 104 | /// This isn't so bad, but at the moment it doesn't even compile. An 105 | /// [evolution proposal](https://github.com/apple/swift-evolution/blob/main/proposals/0312-indexed-and-enumerated-zip-collections.md) 106 | /// may change that soon, but in the meantime `List` and `ForEach` must be passed a 107 | /// `RandomAccessCollection`, which is perhaps most simply achieved by constructing another array: 108 | /// 109 | /// ```swift 110 | /// List(Array(self.viewModel.todos.enumerated()), id: \.element.id) { index, todo in 111 | /// ... 112 | /// } 113 | /// ``` 114 | /// 115 | /// This compiles, but we've just moved the performance problem to the view: every time this body is 116 | /// evaluated there's the possibility a whole new array is being allocated. 117 | /// 118 | /// But even if it were possible to pass an enumerated collection directly to these views, 119 | /// identifying an element of mutable state by an index introduces a number of other problems. 120 | /// 121 | /// While it's true that we can greatly simplify and improve the performance of any view model 122 | /// methods that mutate an element through its index subscript: 123 | /// 124 | /// ```swift 125 | /// class TodosViewModel: ObservableObject { 126 | /// ... 127 | /// func todoCheckboxToggled(at index: Int) { 128 | /// self.todos[index].isComplete.toggle() 129 | /// // TODO: sync with API 130 | /// } 131 | /// } 132 | /// ``` 133 | /// 134 | /// Any asynchronous work that we add to this endpoint must take great care in _not_ using this 135 | /// index later on. An index is not a stable identifier: todos can be moved and removed at any time, 136 | /// and an index identifying "Buy lettuce" at one moment may identify "Call Mom" the next, or worse, 137 | /// may be a completely invalid index and crash your application! 138 | /// 139 | /// ```swift 140 | /// class TodosViewModel: ObservableObject { 141 | /// ... 142 | /// func todoCheckboxToggled(at index: Int) async { 143 | /// self.todos[index].isComplete.toggle() 144 | /// 145 | /// do { 146 | /// // ❌ Could update the wrong todo, or crash! 147 | /// self.todos[index] = try await self.apiClient.updateTodo(self.todos[index]) 148 | /// } catch { 149 | /// // Handle error 150 | /// } 151 | /// } 152 | /// } 153 | /// ``` 154 | /// 155 | /// Whenever you need to access a particular todo after performing some asynchronous work, you 156 | /// _must_ do the work of traversing the array: 157 | /// 158 | /// ```swift 159 | /// class TodosViewModel: ObservableObject { 160 | /// ... 161 | /// func todoCheckboxToggled(at index: Int) async { 162 | /// self.todos[index].isComplete.toggle() 163 | /// 164 | /// // 1️⃣ Get a reference to the todo's id before kicking off the async work 165 | /// let id = self.todos[index].id 166 | /// 167 | /// do { 168 | /// // 2️⃣ Update the todo on the backend 169 | /// let updatedTodo = try await self.apiClient.updateTodo(self.todos[index]) 170 | /// 171 | /// // 3️⃣ Find the updated index of the todo after the async work is done 172 | /// let updatedIndex = self.todos.firstIndex(where: { $0.id == id })! 173 | /// 174 | /// // 4️⃣ Update the correct todo 175 | /// self.todos[updatedIndex] = updatedTodo 176 | /// } catch { 177 | /// // Handle error 178 | /// } 179 | /// } 180 | /// } 181 | /// ``` 182 | /// 183 | /// Identified collections are designed to solve all of these problems by providing data structures 184 | /// for working with collections of identifiable elements in an ergonomic, performant way. 185 | /// 186 | /// Most of the time, you can simply swap an `Array` out for an ``IdentifiedArray``: 187 | /// 188 | /// ```swift 189 | /// import IdentifiedCollections 190 | /// 191 | /// class TodosViewModel: ObservableObject { 192 | /// @Published var todos: IdentifiedArrayOf = [] 193 | /// ... 194 | /// } 195 | /// ``` 196 | /// 197 | /// Here we use ``IdentifiedArrayOf`` generic over `Todo` as a shorthand for 198 | /// `IdentifiedArray`. 199 | /// 200 | /// And then you can mutate an element directly via its id-based subscript, no traversals needed, 201 | /// even after asynchronous work is performed: 202 | /// 203 | /// ```swift 204 | /// class TodosViewModel: ObservableObject { 205 | /// ... 206 | /// func todoCheckboxToggled(at id: Todo.ID) async { 207 | /// self.todos[id: id]?.isComplete.toggle() 208 | /// 209 | /// do { 210 | /// // 1️⃣ Update todo on backend and mutate it in the todos identified array. 211 | /// self.todos[id: id] = try await self.apiClient.updateTodo(self.todos[id: id]!) 212 | /// } catch { 213 | /// // Handle error 214 | /// } 215 | /// 216 | /// // No step 2️⃣ 😆 217 | /// } 218 | /// } 219 | /// ``` 220 | /// 221 | /// You can also simply pass the identified array to views like `List` and `ForEach` without any 222 | /// complications: 223 | /// 224 | /// ```swift 225 | /// List(self.viewModel.todos) { todo in 226 | /// ... 227 | /// } 228 | /// ``` 229 | /// 230 | /// # Sequence and Collection Operations 231 | /// 232 | /// Identified arrays are random-access collections. Members are assigned integer indices, with the 233 | /// first element always being at index `0`. 234 | /// 235 | /// # Performance 236 | /// 237 | /// Like the standard `Dictionary` type, the performance of hashing operations in 238 | /// ``IdentifiedArray`` is highly sensitive to the quality of hashing implemented by the `ID` 239 | /// type. Failing to correctly implement hashing can easily lead to unacceptable performance, with 240 | /// the severity of the effect increasing with the size of the underlying hash table. 241 | /// 242 | /// In particular, if a certain set of elements all produce the same hash value, then hash table 243 | /// lookups regress to searching an element in an unsorted array, i.e., a linear operation. To 244 | /// ensure hashed collection types exhibit their target performance, it is important to ensure that 245 | /// such collisions cannot be induced merely by adding a particular list of members to the set. 246 | /// 247 | /// The easiest way to achieve this is to make sure `ID` implements hashing following `Hashable`'s 248 | /// documented best practices. The conformance must implement the `hash(into:)` requirement, and 249 | /// every bit of information that is compared in `==` needs to be combined into the supplied 250 | /// `Hasher` value. When used correctly, `Hasher` produces high-quality, randomly seeded hash values 251 | /// that prevent repeatable hash collisions. 252 | /// 253 | /// When `ID` implements `Hashable` correctly, testing for membership in an ordered set is expected 254 | /// to take O(1) equality checks on average. Hash collisions can still occur organically, so the 255 | /// worst-case lookup performance is technically still O(*n*) (where *n* is the size of the set); 256 | /// however, long lookup chains are unlikely to occur in practice. 257 | /// 258 | /// ## Implementation Details 259 | /// 260 | /// An identified array consists of an ordered dictionary of id-element pairs. An element's id 261 | /// should not be mutated in place, as it will drift from its associated dictionary key. Identified 262 | /// array is designed to avoid this invariant, with the exception of its *id-based* subscript. 263 | /// Mutating an element's id will result in a runtime error. 264 | public struct IdentifiedArray { 265 | public let id: KeyPath 266 | 267 | // NB: Captures identity access. Direct access to `Identifiable`'s `.id` property is faster than 268 | // key path access. 269 | @usableFromInline 270 | var _id: (Element) -> ID 271 | 272 | @usableFromInline 273 | var _dictionary: OrderedDictionary 274 | 275 | /// A read-only collection view for the elements contained in this array, as an `Array`. 276 | /// 277 | /// - Complexity: O(1) 278 | @inlinable 279 | @inline(__always) 280 | public var elements: [Element] { self._dictionary.values.elements } 281 | 282 | @usableFromInline 283 | init( 284 | id: KeyPath, 285 | _id: @escaping (Element) -> ID, 286 | _dictionary: OrderedDictionary 287 | ) { 288 | self.id = id 289 | self._id = _id 290 | self._dictionary = _dictionary 291 | } 292 | 293 | @inlinable 294 | public func contains(_ element: Element) -> Bool { 295 | self._dictionary[self._id(element)] != nil 296 | } 297 | 298 | /// Returns the index for the given id. 299 | /// 300 | /// If an element identified by the given id is found in the array, this method returns an index 301 | /// into the array that corresponds to the element. 302 | /// 303 | /// ```swift 304 | /// struct User: Identifiable { var id: String } 305 | /// let users: IdentifiedArray = [ 306 | /// User(id: "u_42"), 307 | /// User(id: "u_1729"), 308 | /// ] 309 | /// users.index(id: "u_1729") // 1 310 | /// users.index(id: "u_1337") // nil 311 | /// ``` 312 | /// 313 | /// - Parameter id: The id to find in the array. 314 | /// - Returns: The index for the element identified by `id` if found in the array; otherwise, 315 | /// `nil`. 316 | /// - Complexity: Expected to be O(1) on average, if `ID` implements high-quality hashing. 317 | @inlinable 318 | @inline(__always) 319 | public func index(id: ID) -> Int? { 320 | self._dictionary.index(forKey: id) 321 | } 322 | 323 | /// Removes the given element from the array. 324 | /// 325 | /// If the element is found in the array, this method returns the element. 326 | /// 327 | /// If the element isn't found in the array, `remove` returns `nil`. 328 | /// 329 | /// - Parameter element: The element to remove. 330 | /// - Returns: The value that was removed, or `nil` if the element was not present in the array. 331 | /// - Complexity: O(`count`) 332 | @inlinable 333 | @discardableResult 334 | public mutating func remove(_ element: Element) -> Element? { 335 | self._dictionary.removeValue(forKey: self._id(element)) 336 | } 337 | 338 | /// Removes the element identified by the given id from the array. 339 | /// 340 | /// ```swift 341 | /// struct User: Identifiable { var id: String } 342 | /// let users: IdentifiedArray = [ 343 | /// User(id: "u_42"), 344 | /// User(id: "u_1729"), 345 | /// ] 346 | /// users.remove(id: "u_1729") // User(id: "u_1729") 347 | /// users // [User(id: "u_42")] 348 | /// users.remove(id: "u_1337") // nil 349 | /// ``` 350 | /// 351 | /// - Parameter id: The id of the element to be removed from the array. 352 | /// - Returns: The element that was removed, or `nil` if the element was not present in the array. 353 | /// - Complexity: O(`count`) 354 | @inlinable 355 | @discardableResult 356 | public mutating func remove(id: ID) -> Element? { 357 | self._dictionary.removeValue(forKey: id) 358 | } 359 | } 360 | 361 | /// A convenience type alias that specifies an ``IdentifiedArray`` by an element conforming to the 362 | /// `Identifiable` protocol. 363 | /// 364 | /// ```swift 365 | /// struct User: Identifiable { var id: String } 366 | /// var users: IdentifiedArrayOf = [] 367 | /// ``` 368 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 369 | public typealias IdentifiedArrayOf = IdentifiedArray 370 | where Element: Identifiable 371 | -------------------------------------------------------------------------------- /Sources/IdentifiedCollections/IdentifiedCollection.swift: -------------------------------------------------------------------------------- 1 | /// A collection of elements that can be uniquely identified. 2 | public protocol _IdentifiedCollection: Collection { 3 | /// A type that uniquely identifies elements in the collection. 4 | associatedtype ID: Hashable 5 | 6 | /// A type that describes all of the ids in the collection. 7 | associatedtype IDs: Collection 8 | 9 | /// A collection of ids associated with elements in the collection. 10 | /// 11 | /// This collection must contain elements equal to `map(\.id)`. 12 | var ids: IDs { get } 13 | 14 | /// Accesses the value associated with the given id for reading. 15 | subscript(id id: ID) -> Element? { get } 16 | } 17 | 18 | /// A mutable collection of elements that can be uniquely identified. 19 | public protocol _MutableIdentifiedCollection: _IdentifiedCollection, MutableCollection 20 | { 21 | /// Accesses the value associated with the given id for reading. 22 | subscript(id id: ID) -> Element? { get set } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/swift-identified-collections-benchmark/main.swift: -------------------------------------------------------------------------------- 1 | import CollectionsBenchmark 2 | import IdentifiedCollections 3 | 4 | #if $RetroactiveAttribute 5 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 6 | extension Int: @retroactive Identifiable { public var id: Self { self } } 7 | #else 8 | @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) 9 | extension Int: Identifiable { public var id: Self { self } } 10 | #endif 11 | 12 | if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { 13 | var benchmark = Benchmark(title: "Identified Collections Benchmark") 14 | 15 | benchmark.addSimple( 16 | title: "IdentifiedArray init from range", 17 | input: Int.self 18 | ) { size in 19 | blackHole(IdentifiedArray(uniqueElements: 0.. init from unsafe buffer", 24 | input: [Int].self 25 | ) { input in 26 | input.withUnsafeBufferPointer { buffer in 27 | blackHole(IdentifiedArray(uniqueElements: buffer)) 28 | } 29 | } 30 | 31 | benchmark.addSimple( 32 | title: "IdentifiedArray init(uncheckedUniqueElements:) from range", 33 | input: Int.self 34 | ) { size in 35 | blackHole(IdentifiedArray(uniqueElements: 0.. random-access offset lookups", 40 | input: ([Int], [Int]).self 41 | ) { input, lookups in 42 | let array = IdentifiedArray(uniqueElements: input) 43 | return { timer in 44 | for i in lookups { 45 | blackHole(array[i]) 46 | } 47 | } 48 | } 49 | 50 | benchmark.add( 51 | title: "IdentifiedArray sequential iteration", 52 | input: [Int].self 53 | ) { input in 54 | let array = IdentifiedArray(uniqueElements: input) 55 | return { timer in 56 | for i in array { 57 | blackHole(i) 58 | } 59 | } 60 | } 61 | 62 | benchmark.add( 63 | title: "IdentifiedArray successful contains", 64 | input: ([Int], [Int]).self 65 | ) { input, lookups in 66 | let array = IdentifiedArray(uniqueElements: input) 67 | return { timer in 68 | for i in lookups { 69 | precondition(array.contains(i)) 70 | } 71 | } 72 | } 73 | 74 | benchmark.add( 75 | title: "IdentifiedArray unsuccessful contains", 76 | input: ([Int], [Int]).self 77 | ) { input, lookups in 78 | let array = IdentifiedArray(uniqueElements: input) 79 | let lookups = lookups.map { $0 + input.count } 80 | return { timer in 81 | for i in lookups { 82 | precondition(!array.contains(i)) 83 | } 84 | } 85 | } 86 | 87 | benchmark.add( 88 | title: "IdentifiedArray random swaps", 89 | input: [Int].self 90 | ) { input in 91 | return { timer in 92 | var array = IdentifiedArray(uniqueElements: 0.. partitioning around middle", 104 | input: [Int].self 105 | ) { input in 106 | return { timer in 107 | let pivot = input.count / 2 108 | var array = IdentifiedArray(uniqueElements: input) 109 | timer.measure { 110 | let r = array.partition(by: { $0 >= pivot }) 111 | precondition(r == pivot) 112 | } 113 | blackHole(array) 114 | } 115 | } 116 | 117 | benchmark.add( 118 | title: "IdentifiedArray sort", 119 | input: [Int].self 120 | ) { input in 121 | return { timer in 122 | var array = IdentifiedArray(uniqueElements: input) 123 | timer.measure { 124 | array.sort() 125 | } 126 | precondition(array.elementsEqual(0.. append", 132 | input: [Int].self 133 | ) { input in 134 | var array: IdentifiedArrayOf = [] 135 | for i in input { 136 | array.append(i) 137 | } 138 | precondition(array.count == input.count) 139 | blackHole(array) 140 | } 141 | 142 | benchmark.addSimple( 143 | title: "IdentifiedArray append, reserving capacity", 144 | input: [Int].self 145 | ) { input in 146 | var array: IdentifiedArray = [] 147 | array.reserveCapacity(input.count) 148 | for i in input { 149 | array.append(i) 150 | } 151 | precondition(array.count == input.count) 152 | blackHole(array) 153 | } 154 | 155 | benchmark.addSimple( 156 | title: "IdentifiedArray prepend", 157 | input: [Int].self 158 | ) { input in 159 | var array: IdentifiedArray = [] 160 | for i in input { 161 | _ = array.insert(i, at: 0) 162 | } 163 | blackHole(array) 164 | } 165 | 166 | benchmark.addSimple( 167 | title: "IdentifiedArray prepend, reserving capacity", 168 | input: [Int].self 169 | ) { input in 170 | var array: IdentifiedArray = [] 171 | array.reserveCapacity(input.count) 172 | for i in input { 173 | _ = array.insert(i, at: 0) 174 | } 175 | blackHole(array) 176 | } 177 | 178 | benchmark.add( 179 | title: "IdentifiedArray random insertions, reserving capacity", 180 | input: Benchmark.Insertions.self 181 | ) { insertions in 182 | return { timer in 183 | let insertions = insertions.values 184 | var array: IdentifiedArray = [] 185 | array.reserveCapacity(insertions.count) 186 | timer.measure { 187 | for i in insertions.indices { 188 | _ = array.insert(i, at: insertions[i]) 189 | } 190 | } 191 | blackHole(array) 192 | } 193 | } 194 | 195 | benchmark.add( 196 | title: "IdentifiedArray remove", 197 | input: ([Int], [Int]).self 198 | ) { input, removals in 199 | return { timer in 200 | var array = IdentifiedArray(uniqueElements: input) 201 | timer.measure { 202 | for i in removals { 203 | array.remove(i) 204 | } 205 | } 206 | precondition(array.isEmpty) 207 | blackHole(array) 208 | } 209 | } 210 | 211 | benchmark.add( 212 | title: "IdentifiedArray removeLast", 213 | input: Int.self 214 | ) { size in 215 | return { timer in 216 | var array = IdentifiedArray(uniqueElements: 0.. removeFirst", 229 | input: Int.self 230 | ) { size in 231 | return { timer in 232 | var array = IdentifiedArray(uniqueElements: 0.. diff computation", 245 | input: ([Int], [Int]).self 246 | ) { pa, pb in 247 | let a = IdentifiedArray(uniqueElements: pa) 248 | let b = IdentifiedArray(uniqueElements: pb) 249 | return { timer in 250 | timer.measure { 251 | blackHole(b.difference(from: a)) 252 | } 253 | } 254 | } 255 | 256 | benchmark.add( 257 | title: "IdentifiedArray diff application", 258 | input: ([Int], [Int]).self 259 | ) { a, b in 260 | let d = IdentifiedArray(uniqueElements: b).difference(from: IdentifiedArray(uniqueElements: a)) 261 | return { timer in 262 | timer.measure { 263 | blackHole(a.applying(d)) 264 | } 265 | } 266 | } 267 | 268 | benchmark.main() 269 | } 270 | -------------------------------------------------------------------------------- /Tests/IdentifiedCollectionsTests/IdentifiedArrayCollectionOperationTests.swift: -------------------------------------------------------------------------------- 1 | import IdentifiedCollections 2 | import XCTest 3 | 4 | final class IdentifiedArrayCollectionOperationsTests: XCTestCase { 5 | func testMax() { 6 | assertElementsEqual { $0.max() } 7 | assertElementsEqual { $0.max(by: >) } 8 | } 9 | func testMin() { 10 | assertElementsEqual { $0.min() } 11 | assertElementsEqual { $0.min(by: >) } 12 | } 13 | func testRemoveFirst() { 14 | assertElementsEqual { $0.isEmpty ? nil : $0.removeFirst() } 15 | } 16 | func testRemoveLast() { 17 | assertElementsEqual { $0.isEmpty ? nil : $0.removeLast() } 18 | } 19 | func testReverse() { 20 | assertElementsEqual { $0.reverse() } 21 | } 22 | func testShuffleUsing() { 23 | var seed: UInt64 = 0 24 | assertElementsEqual { 25 | var rng = LCRNG(seed: seed) 26 | $0.shuffle(using: &rng) 27 | } setUp: { 28 | seed = .random(in: .min ... .max) 29 | } 30 | } 31 | func testSort() { 32 | assertElementsEqual { $0.sort() } 33 | assertElementsEqual { $0.sort(by: >) } 34 | } 35 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 36 | func testSortUsing() { 37 | assertElementsEqual { $0.sort(using: KeyPathComparator(\.count)) } 38 | assertElementsEqual { $0.sort(using: KeyPathComparator(\.count, order: .reverse)) } 39 | } 40 | #endif 41 | } 42 | 43 | private struct Item: Identifiable, Comparable, Equatable { 44 | let id = UUID() 45 | var count: Int 46 | 47 | static func < (lhs: Self, rhs: Self) -> Bool { 48 | lhs.count < rhs.count 49 | } 50 | } 51 | 52 | private protocol TestCollection: 53 | MutableCollection, RandomAccessCollection, RangeReplaceableCollection 54 | {} 55 | 56 | extension Array: TestCollection {} 57 | extension IdentifiedArray: TestCollection where Element: Identifiable, Element.ID == ID {} 58 | 59 | private func assertElementsEqual( 60 | after operation: (inout (any TestCollection)) -> Result, 61 | setUp: () -> Void = {}, 62 | file: StaticString = #file, 63 | line: UInt = #line 64 | ) { 65 | for n in 0...10 { 66 | let size = Int(pow(Double(n), 2)) 67 | for _ in 1...10 { 68 | setUp() 69 | var identifiedArray: IdentifiedArrayOf = [] 70 | for _ in 0.. 74 | var anyArray = Array(identifiedArray) as any TestCollection 75 | let lhs = operation(&anyIdentifiedArray) 76 | let rhs = operation(&anyArray) 77 | XCTAssert( 78 | anyIdentifiedArray.elementsEqual(anyArray), 79 | """ 80 | (\(anyIdentifiedArray)) does not equal control (\(anyArray)) 81 | """, 82 | file: file, 83 | line: line 84 | ) 85 | identifiedArray = anyIdentifiedArray as! IdentifiedArrayOf 86 | XCTAssert( 87 | identifiedArray.ids.elementsEqual(identifiedArray.map(\.id)), 88 | """ 89 | (\(identifiedArray.ids)) keys does not equal IDs (\(identifiedArray.map(\.id))) 90 | """, 91 | file: file, 92 | line: line 93 | ) 94 | if let lhs = lhs as? any Equatable { 95 | func open(_ lhs: LHS) { 96 | if let rhs = rhs as? LHS { 97 | XCTAssertEqual(lhs, rhs, file: file, line: line) 98 | } 99 | } 100 | open(lhs) 101 | } 102 | if size == 0 { 103 | continue 104 | } 105 | } 106 | } 107 | } 108 | 109 | struct LCRNG: RandomNumberGenerator { 110 | var seed: UInt64 111 | init(seed: UInt64 = 0) { 112 | self.seed = seed 113 | } 114 | mutating func next() -> UInt64 { 115 | self.seed = 2_862_933_555_777_941_757 &* self.seed &+ 3_037_000_493 116 | return self.seed 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Tests/IdentifiedCollectionsTests/IdentifiedArrayTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import IdentifiedCollections 4 | 5 | #if $RetroactiveAttribute 6 | extension Int: @retroactive Identifiable { public var id: Self { self } } 7 | #else 8 | extension Int: Identifiable { public var id: Self { self } } 9 | #endif 10 | 11 | private struct User: Equatable, Identifiable { 12 | let id: Int 13 | var name: String 14 | } 15 | 16 | final class IdentifiedArrayTests: XCTestCase { 17 | func testIds() { 18 | let array: IdentifiedArray = [1, 2, 3] 19 | XCTAssertEqual(array.ids, [1, 2, 3]) 20 | } 21 | 22 | func testElements() { 23 | let array: IdentifiedArray = [ 24 | User(id: 1, name: "Blob"), 25 | User(id: 2, name: "Blob, Jr."), 26 | User(id: 3, name: "Blob, Sr."), 27 | ] 28 | 29 | XCTAssertEqual(array.elements, array.map { $0 }) 30 | } 31 | 32 | func testSubscriptId() { 33 | var array: IdentifiedArray = [ 34 | User(id: 1, name: "Blob"), 35 | User(id: 2, name: "Blob, Jr."), 36 | User(id: 3, name: "Blob, Sr."), 37 | ] 38 | XCTAssertEqual(array[id: 1], User(id: 1, name: "Blob")) 39 | XCTAssertEqual(array[id: 2], User(id: 2, name: "Blob, Jr.")) 40 | XCTAssertEqual(array[id: 3], User(id: 3, name: "Blob, Sr.")) 41 | array[id: 1]?.name += ", Esq." 42 | XCTAssertEqual(array[id: 1], User(id: 1, name: "Blob, Esq.")) 43 | array[id: 2]?.name.removeLast(5) 44 | XCTAssertEqual(array[id: 2], User(id: 2, name: "Blob")) 45 | array[id: 3]?.name.removeLast(5) 46 | XCTAssertEqual(array[id: 3], User(id: 3, name: "Blob")) 47 | array[id: 3] = nil 48 | XCTAssertEqual(array[id: 3], nil) 49 | 50 | array[id: 4] = User(id: 4, name: "Blob, Sr.") 51 | XCTAssertEqual( 52 | array, 53 | [ 54 | User(id: 1, name: "Blob, Esq."), 55 | User(id: 2, name: "Blob"), 56 | User(id: 4, name: "Blob, Sr."), 57 | ] 58 | ) 59 | } 60 | 61 | func testContainsElement() { 62 | let array: IdentifiedArray = [1, 2, 3] 63 | XCTAssertTrue(array.contains(2)) 64 | } 65 | 66 | func testIndexId() { 67 | let array: IdentifiedArray = [1, 2, 3] 68 | XCTAssertEqual(array.index(id: 2), 1) 69 | } 70 | 71 | func testRemoveElement() { 72 | var array: IdentifiedArray = [1, 2, 3] 73 | XCTAssertEqual(array.remove(2), 2) 74 | XCTAssertEqual(array, [1, 3]) 75 | } 76 | 77 | func testRemoveId() { 78 | var array: IdentifiedArray = [1, 2, 3] 79 | XCTAssertEqual(array.remove(id: 2), 2) 80 | XCTAssertEqual(array, [1, 3]) 81 | } 82 | 83 | func testCodable() { 84 | let array: IdentifiedArray = [1, 2, 3] 85 | XCTAssertEqual( 86 | try JSONDecoder().decode(IdentifiedArray.self, from: JSONEncoder().encode(array)), 87 | array 88 | ) 89 | XCTAssertEqual( 90 | try JSONDecoder().decode(IdentifiedArray.self, from: Data("[1,2,3]".utf8)), 91 | array 92 | ) 93 | XCTAssertThrowsError( 94 | try JSONDecoder().decode(IdentifiedArrayOf.self, from: Data("[1,1,1]".utf8)) 95 | ) { error in 96 | guard case let DecodingError.dataCorrupted(ctx) = error 97 | else { return XCTFail() } 98 | XCTAssertEqual(ctx.debugDescription, "Duplicate element at offset 1") 99 | } 100 | } 101 | 102 | func testCustomDebugStringConvertible() { 103 | let array: IdentifiedArray = [1, 2, 3] 104 | XCTAssertEqual(array.debugDescription, "IdentifiedArray([1, 2, 3])") 105 | } 106 | 107 | func testCustomReflectable() { 108 | let array: IdentifiedArray = [1, 2, 3] 109 | let mirror = Mirror(reflecting: array) 110 | XCTAssertEqual(mirror.displayStyle, .collection) 111 | XCTAssert(mirror.superclassMirror == nil) 112 | XCTAssertEqual(mirror.children.compactMap { $0.label }.isEmpty, true) 113 | XCTAssertEqual(mirror.children.map { $0.value as? Int }, array.map { $0 }) 114 | } 115 | 116 | func testCustomStringConvertible() { 117 | let array: IdentifiedArray = [1, 2, 3] 118 | XCTAssertEqual(array.description, "[1, 2, 3]") 119 | } 120 | 121 | func testHashable() { 122 | let array: IdentifiedArray = [1, 2, 3] 123 | XCTAssertEqual(Set([array]), Set([array, array])) 124 | } 125 | 126 | func testInitUncheckedUniqueElements() { 127 | let array = IdentifiedArray(uncheckedUniqueElements: [1, 2, 3]) 128 | XCTAssertEqual(array, [1, 2, 3]) 129 | } 130 | 131 | func testInitUniqueElementsSelf() { 132 | let array: IdentifiedArray = [1, 2, 3] 133 | XCTAssertEqual(IdentifiedArray(uniqueElements: array), [1, 2, 3]) 134 | } 135 | 136 | func testInitUniqueElementsSubSequence() { 137 | let array: IdentifiedArray = [1, 2, 3] 138 | XCTAssertEqual(IdentifiedArray(uniqueElements: array[...]), [1, 2, 3]) 139 | } 140 | 141 | func testInitUniqueElements() { 142 | let array = IdentifiedArray(uniqueElements: [1, 2, 3]) 143 | XCTAssertEqual(array, [1, 2, 3]) 144 | } 145 | 146 | func testSelfInit() { 147 | let array: IdentifiedArray = [1, 2, 3] 148 | XCTAssertEqual(IdentifiedArray(array), [1, 2, 3]) 149 | } 150 | 151 | func testSubsequenceInit() { 152 | let array: IdentifiedArray = [1, 2, 3] 153 | XCTAssertEqual(IdentifiedArray(array[...]), [1, 2, 3]) 154 | } 155 | 156 | func testInitIDUniquingElements() { 157 | struct Model: Equatable { 158 | let id: Int 159 | let data: String 160 | } 161 | // Choose first element 162 | do { 163 | let array = IdentifiedArray( 164 | [ 165 | Model(id: 1, data: "A"), 166 | Model(id: 2, data: "B"), 167 | Model(id: 1, data: "AAAA"), 168 | ], 169 | id: \.id 170 | ) { lhs, _ in lhs } 171 | 172 | XCTAssertEqual( 173 | array, 174 | IdentifiedArray( 175 | uniqueElements: [ 176 | Model(id: 1, data: "A"), 177 | Model(id: 2, data: "B"), 178 | ], 179 | id: \.id 180 | ) 181 | ) 182 | } 183 | // Choose later element 184 | do { 185 | let array = IdentifiedArray( 186 | [ 187 | Model(id: 1, data: "A"), 188 | Model(id: 2, data: "B"), 189 | Model(id: 1, data: "AAAA"), 190 | ], 191 | id: \.id 192 | ) { _, rhs in rhs } 193 | 194 | XCTAssertEqual( 195 | array, 196 | IdentifiedArray( 197 | uniqueElements: [ 198 | Model(id: 1, data: "AAAA"), 199 | Model(id: 2, data: "B"), 200 | ], id: \.id)) 201 | } 202 | } 203 | 204 | func testInitUniquingElements() { 205 | struct Model: Equatable, Identifiable { 206 | let id: Int 207 | let data: String 208 | } 209 | // Choose first element 210 | do { 211 | let array = IdentifiedArray( 212 | [ 213 | Model(id: 1, data: "A"), 214 | Model(id: 2, data: "B"), 215 | Model(id: 1, data: "AAAA"), 216 | ] 217 | ) { lhs, _ in lhs } 218 | 219 | XCTAssertEqual( 220 | array, 221 | IdentifiedArray( 222 | uniqueElements: [ 223 | Model(id: 1, data: "A"), 224 | Model(id: 2, data: "B"), 225 | ] 226 | ) 227 | ) 228 | } 229 | // Choose later element 230 | do { 231 | let array = IdentifiedArray( 232 | [ 233 | Model(id: 1, data: "A"), 234 | Model(id: 2, data: "B"), 235 | Model(id: 1, data: "AAAA"), 236 | ] 237 | ) { _, rhs in rhs } 238 | 239 | XCTAssertEqual( 240 | array, 241 | IdentifiedArray( 242 | uniqueElements: [ 243 | Model(id: 1, data: "AAAA"), 244 | Model(id: 2, data: "B"), 245 | ] 246 | ) 247 | ) 248 | } 249 | } 250 | 251 | func testAppend() { 252 | var array: IdentifiedArray = [1, 2, 3] 253 | var (inserted, index) = array.append(4) 254 | XCTAssertEqual(inserted, true) 255 | XCTAssertEqual(index, 3) 256 | XCTAssertEqual(array, [1, 2, 3, 4]) 257 | (inserted, index) = array.append(2) 258 | XCTAssertEqual(inserted, false) 259 | XCTAssertEqual(index, 1) 260 | XCTAssertEqual(array, [1, 2, 3, 4]) 261 | } 262 | 263 | func testAppendContentsOf() { 264 | var array: IdentifiedArray = [1, 2, 3] 265 | array.append(contentsOf: [1, 4, 3, 5]) 266 | XCTAssertEqual(array, [1, 2, 3, 4, 5]) 267 | } 268 | 269 | func testInsert() { 270 | var array: IdentifiedArray = [1, 2, 3] 271 | var (inserted, index) = array.insert(0, at: 0) 272 | XCTAssertEqual(inserted, true) 273 | XCTAssertEqual(index, 0) 274 | XCTAssertEqual(array, [0, 1, 2, 3]) 275 | (inserted, index) = array.insert(2, at: 0) 276 | XCTAssertEqual(inserted, false) 277 | XCTAssertEqual(index, 2) 278 | XCTAssertEqual(array, [0, 1, 2, 3]) 279 | } 280 | 281 | func testUpdateAt() { 282 | var array: IdentifiedArray = [1, 2, 3] 283 | XCTAssertEqual(array.update(2, at: 1), 2) 284 | } 285 | 286 | func testUpdateOrAppend() { 287 | var array: IdentifiedArray = [1, 2, 3] 288 | XCTAssertEqual(array.updateOrAppend(4), nil) 289 | XCTAssertEqual(array, [1, 2, 3, 4]) 290 | XCTAssertEqual(array.updateOrAppend(2), 2) 291 | } 292 | 293 | func testUpdateOrInsert() { 294 | var array: IdentifiedArray = [1, 2, 3] 295 | var (originalMember, index) = array.updateOrInsert(0, at: 0) 296 | XCTAssertEqual(originalMember, nil) 297 | XCTAssertEqual(index, 0) 298 | XCTAssertEqual(array, [0, 1, 2, 3]) 299 | (originalMember, index) = array.updateOrInsert(2, at: 0) 300 | XCTAssertEqual(originalMember, 2) 301 | XCTAssertEqual(index, 2) 302 | XCTAssertEqual(array, [0, 1, 2, 3]) 303 | } 304 | 305 | func testPartition() { 306 | var array: IdentifiedArray = [1, 2] 307 | 308 | let index = array.partition { $0.id == 1 } 309 | 310 | XCTAssertEqual(index, 1) 311 | XCTAssertEqual(array, [2, 1]) 312 | 313 | for id in array.ids { 314 | XCTAssertEqual(id, array[id: id]?.id) 315 | } 316 | } 317 | 318 | func testMoveFromOffsetsToOffset() { 319 | var array: IdentifiedArray = [1, 2, 3] 320 | array.move(fromOffsets: [0, 2], toOffset: 0) 321 | XCTAssertEqual(array, [1, 3, 2]) 322 | 323 | array = [1, 2, 3] 324 | array.move(fromOffsets: [0, 2], toOffset: 1) 325 | XCTAssertEqual(array, [1, 3, 2]) 326 | 327 | array = [1, 2, 3] 328 | array.move(fromOffsets: [0, 2], toOffset: 2) 329 | XCTAssertEqual(array, [2, 1, 3]) 330 | } 331 | 332 | func testRemoveAtOffsets() { 333 | var array: IdentifiedArray = [1, 2, 3] 334 | array.remove(atOffsets: [0, 2]) 335 | XCTAssertEqual(array, [2]) 336 | } 337 | func testEquatable() { 338 | struct Foo: Identifiable, Equatable { 339 | var id: String = "id" 340 | var value: String = "value" 341 | } 342 | // Create arrays using all of the initializers 343 | var arrays: [IdentifiedArray] = [ 344 | IdentifiedArray(), 345 | IdentifiedArray(uncheckedUniqueElements: [], id: \.id), 346 | IdentifiedArray(uniqueElements: [], id: \.id), 347 | IdentifiedArray(uncheckedUniqueElements: []), 348 | IdentifiedArray(uniqueElements: []), 349 | ] 350 | arrays.forEach({ lhs in 351 | arrays.forEach({ rhs in 352 | XCTAssertEqual(lhs, rhs) 353 | }) 354 | }) 355 | // add an element to each array 356 | arrays.indices.forEach({ 357 | arrays[$0].append(Foo()) 358 | }) 359 | arrays.forEach({ lhs in 360 | arrays.forEach({ rhs in 361 | XCTAssertEqual(lhs, rhs) 362 | }) 363 | }) 364 | // modify all arrays 365 | arrays.indices.forEach({ 366 | arrays[$0].append(Foo(id: "id2", value: "\($0)")) 367 | }) 368 | arrays.enumerated().forEach({ lhsIndex, lhs in 369 | arrays.enumerated().forEach({ rhsIndex, rhs in 370 | guard rhsIndex != lhsIndex else { return } 371 | XCTAssertNotEqual(lhs, rhs) 372 | }) 373 | }) 374 | } 375 | 376 | func testIdentifiedArraySubscript() { 377 | struct Item: Identifiable { 378 | let id: Int 379 | } 380 | var items: IdentifiedArrayOf = [Item(id: 1), Item(id: 2), Item(id: 3)] 381 | items[1] = Item(id: 4) 382 | XCTAssertEqual(3, items.count) 383 | XCTAssertEqual([1, 4, 3], items.map(\.id)) 384 | } 385 | 386 | #if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) 387 | func testIdentifiedArrayComparatorSort() { 388 | struct Item: Identifiable { 389 | let id: Int 390 | } 391 | var items: IdentifiedArrayOf = [Item(id: 3), Item(id: 2), Item(id: 1)] 392 | items.sort(using: KeyPathComparator(\.id)) 393 | XCTAssertEqual([1, 2, 3], items.map(\.id)) 394 | } 395 | #endif 396 | } 397 | --------------------------------------------------------------------------------