Map enumerator as keys for dictionary

Hi
I'm very new to Swift, and recentely I've got stuck to map enumerator as keys for dictionary:
So I have the following data:

struct Account {
    let name: String
    let surname: String
}

let accountsArray = [
    Account(name: "Sonia", surname: "Kathy"),
    Account(name: "Philipa", surname: "Mayson"),
    Account(name: "Arabella", surname: "Catharine"),
    Account(name: "Judd", surname: "Patsy"),
    Account(name: "Demi", surname: "Kayson"),
    Account(name: "Antony", surname: "Caisy"),
    Account(name: "Jane", surname: "Gimley")
]

enum Alphabet : String, CaseIterable {
    case A
    case B
    case C
    case D
    case E
    case F
    case G
    case H
    case I
    case J
    case K
    case L
    case M
    case N
    case O
    case P
    case Q
    case R
    case S
    case T
    case U
    case V
    case W
    case X
    case Y
    case Z
    case hash
}

let testMapEnumDict = {( parameter1: Alphabet, parameter2: [Student]) -> [Alphabet: [Student]] in
    let emptyDict: [Alphabet: [Student]]  = [:]
    let groupedDictionary = Dictionary(grouping: accountsArray, by: {$0.name.first!})
    Alphabet.allCases.forEach{
        if $0 == 
    }
    
}

How can I map a dicitonary with letters as keys with enumerator to get [Alphabet: [Student]] type?
Thanks!

Here's my solution to that problem:

struct Student {
    var name: String
    var surname: String
}

enum Letter : String, CaseIterable {
    case A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, hash
}

let students = [
    Student(name: "Arabella", surname: "Catharine"),
    Student(name: "Antony", surname: "Caisy"),
    Student(name: "Łukasz", surname: "Patsy"),
    Student(name: "hash", surname: "Foo"),
    Student(name: "bob", surname: "gimley"),
    Student(name: "", surname: ""),
]

func groupStudentsByFirstLetter(_ students: [Student]) -> [Letter? : [Student]] {
    func letterForStudent(_ student: Student) -> Letter? {
        Letter.allCases.first(where: {
            student.name.starts(with: $0.rawValue)
        })
    }

    return Dictionary(grouping: students, by: letterForStudent)
}

print(groupStudentsByFirstLetter(students))
/*
[
    Optional(Letter.hash): [Student(name: "hash", surname: "Foo")], 
    Optional(Letter.A): [Student(name: "Arabella", surname: "Catharine"), Student(name: "Antony", surname: "Caisy")], 
    nil: [Student(name: "Łukasz", surname: "Patsy"), Student(name: "bob", surname: "gimley"), Student(name: "", surname: "")]
]
*/

func groupStudentsByFirstCharacter(_ students: [Student]) -> [Character? : [Student]] {
    return Dictionary(grouping: students, by: { $0.name.first })
}
print(groupStudentsByFirstCharacter(students))
/*
[
    nil: [Student(name: "", surname: "")], 
    Optional("h"): [Student(name: "hash", surname: "Foo")], 
    Optional("A"): [Student(name: "Arabella", surname: "Catharine"), Student(name: "Antony", surname: "Caisy")], 
    Optional("Ł"): [Student(name: "Łukasz", surname: "Patsy")], 
    Optional("b"): [Student(name: "bob", surname: "gimley")]
]
*/

I've made two versions of the method, one that groups by the enum you wrote (groupStudentsByFirstLetter) and one that groups by the first Character in the name (groupStudentsByFirstCharacter)

Parts that you should pay attention to:

  • first(where: { ... }) will return the first element that satisfies some condition, in this case that the name of the student starts with that element
  • .rawValue will give you the name of an enum case as a String
  • Character in Swift is an "unicode extended grapheme cluster", which differs from most of programming languages

Notice how I made the key of the dictionary optional. It's because there are situations where a student doesn't fit into any letter. What should happen in that case?
Should "Bob" and "bob" be filed under the same key?
What to do with the letters you didn't think about, like "Ł"?
Is "hash" even a letter?

some stylistic choices I made, but are not important for the code to work
  • changed let to var in the struct definition. In structs it doesn't protect against anything if you doesn't restrict the init.
  • I moved all the enum cases into a single line. That way is much easier to read if case names are super short like in your code
  • I renamed Alphabet to Letter. In Swift we name the type in a way that describes a single instance, not the whole type in aggregate.
  • studentsArray -> students because we usually don't care what type of collection a variable is. The only thing we need to know is that it's a collection of students - plural name is enough for that
  • I used full functions instead of closures. I find it very helpful to be more verbose when you're struggling with finding the correct types
  • trailing comma in an array literal is cool
1 Like

Assuming Student is Account:

let groupedDictionary = Dictionary(grouping: accountsArray) { $0.name.first! }
let result = Dictionary(uniqueKeysWithValues: groupedDictionary.map {
    (Alphabet(rawValue: String($0))!, $1)
})

or, simpler:

let result = Dictionary(grouping: accountsArray) {
    Alphabet(rawValue: String($0.name.first!))!
}

Your solution will crash on realistic data

I left changing from ! to ?? .hash to the original poster, that part is trivial.

That’s an extremely bad habit, and never worth simplifying. Especially for a beginner.

It is not difficult to handle optionals safely from the start. It is much harder to do so retroactively. It’s all about limiting possible state.

4 Likes

Awesome! :) Thank you so much! As far I understood that Dictionary (grouping:, by: ) allows to map Enum and Dictionary keys by returning a value from Dictionary ( groupStudentsByFirstLetter ) or no?

Why will it crash on real data?

It crashes if a name doesn’t start with one of those capital letters.

I recommend skipping the Alphabet enumeration entirely.

// [Character?: [Account]]
let result = Dictionary(grouping: accountsArray, by: \.name.first)

That’s it, you’re done. Note that the key is Character?: it’s possible for name to be an empty string, meaning the first character is nil.[1]

I can explain it further if you have any questions.


  1. If you don’t want to allow that, document the requirement that name isn’t an empty string then check it as a precondition and unwrap it. Personally, I see no reason to prohibit it, as nil works perfectly fine here. ↩︎

2 Likes

I tried to reproduce one of raywenderlich tutorials about sectioning, but instead of using two cases in enum, I thought about the enum with alphabet letters and it was like getting a headshot. Without enum for me it's much easier but then I don't know how to create a Section Row and then add it into ContentView:

private struct SectionView: View {
  let section: Section
  @EnvironmentObject var library: Library
  
  var title: String {
    switch section {
    case .readMe:
      return "Read Me!"
    case .finished:
      return "Finished!"
    }
  }
  
  var body: some View {
    if let books = library.sortedBooks[section] {
      SwiftUI.Section {
        ForEach(books) { book in
          BookRow(book: book)
        }
      } header: {
        ZStack {
          Image("BookTexture")
            .resizable()
            .scaledToFit()
          
          Text(title)
            .font(.custom("American Typewriter", size: 24))
            .foregroundColor(.primary)
        }
        .listRowInsets(.init())
      }

    }
  }
}

Yes, you were right! We don't enum here and we can use firtsname characters as keys. Many thanks!!!

1 Like

Keep in mind the original implementation has a hard limit of 26 buckets, but Character based keying can produce as many bins are there are distinct Characters in Swift, which is a lot.

It would crash on exceptions. Is that better?

1 Like

Would be reasonable to have up to 26 'A' to 'Z' buckets + a 'hash' bucket for numbers, punctuation, etc. Whether to put "Å" into "A", "Ç" into "C", etc - looks reasonable to me, probably there is a unicode savvy way to sensibly strip diacritics from a letter if it is allowed according to the current language rules and not strip it when it is not allowed (in which case the diacriticed name will go into the hash bucket). The obvious complication would be having strings in different (and unknown) languages, and no determinate way to get language/locale of a given arbitrary string. Can well be the case that OP doesn't want to deal with all these complications (e.g. it's a list of students with ascii only names, not realistic names).

Always use Unicode Normalization Forms for this sort of thing. Form C is probably a safe pick here, if you want to group things together.

I recommend worrying about that if it comes up, not in advance. It complicates things considerably.

1 Like

Indeed: