maplemaple
maplemaple

Reputation: 1725

Python: How to sort a list of custom objects by multiple attributes by different order?

For example, if I have a Person class

class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __repr__(self) -> str:
        return f"({self.name}, {self.age})"

and a List of Person

persons = [
    Person("Bob", 25),
    Person("Alice", 25),
    Person("Charlie", 23),
    Person("Dave", 25),
]

I can sort the list by age in ascending order, and in case of a tie, sort by name in ascending order using the following method:

sorted_persons = sorted(persons, key=lambda p: (p.age, p.name))

Question:

However, I'm looking for a way to sort the list by age in ascending order and, in the event of a tie in age, sort by name in descending order. How could I achieve this in Python?

I've come up with one solution, as shown below, but it seems a bit inelegant. Is there a more succinct way to write a string comparison method that can handle all three cases (i.e., less than, equal to, and greater than)? For instance, Java has a s1.compareTo(s2) method that makes such comparisons straightforward.

Here's the solution I'm currently working with:

from functools import cmp_to_key


def compare(p1, p2):
    cmp = p1.age - p2.age
    if cmp != 0:
        return cmp
    if p1.name < p2.name:
        return 1
    elif p1.name > p2.name:
        return -1
    return 0


sorted_persons = sorted(persons, key=cmp_to_key(compare))

This code correctly sorts the persons list first by age in ascending order, and then by name in descending order when the ages are equal. However, I feel there should be a cleaner, more Pythonic way to handle this. Any suggestions?

Upvotes: 2

Views: 1255

Answers (5)

Terry Tsay
Terry Tsay

Reputation: 191

The generic solution of creating the descending class from Alain T. is definitely a better way to solve this generically. One little improvement - __eq__ must also be implemented. Otherwise, the sort attribute following it will not be considered as equal and thus retaining the original list order.

In the example below, without the __eq__, for age 25 and salary 60000, it will be ordered as Charlie->Ally->Billy.

class descending():
    def __init__(self,value):  self.value = value
    def __lt__(self, other):    return self.value > other.value
    def __eq__(self, other): return self.value == other.value

data = [
    {"name": "Alice", "age": 30, "salary": 50000},
    {"name": "Bob", "age": 25, "salary": 70000},
    {"name": "Charlie", "age": 25, "salary": 60000},
    {"name": "Ally", "age": 25, "salary": 60000},
    {"name": "Billy", "age": 25, "salary": 60000},
]

data.sort(key=lambda x: (x["age"], descending(x["salary"]), x["name"]))

Upvotes: 0

Alain T.
Alain T.

Reputation: 42133

Python's sort is stable. This means that the order of items that have the same sort key is preserved in the resulting list.

You can leverage this by sorting in steps, starting with the least significant order:

sorted_persons = sorted(persons, key=lambda p: p.name, reverse=True)
sorted_persons.sort(key=lambda p: p.age)

You could also streamline the cmp_to_key to a lambda (but that's not a different solution):

sorted_persons = sorted(persons,key=cmp_to_key(
                 lambda a,b: a.age-b.age or (a.name<b.name)-(b.name<a.name)))

For a more general solution, you could create a class that will handle all descending key ordering expressed directly in the key=lambda's return tuples:

class descending():
    def __init__(self,value):  self.value = value
    def __lt__(self,other):    return self.value > other.value

usage:

sorted_persons = sorted( persons, key=lambda p:(p.age,descending(p.name)) )

Upvotes: 2

Andrej Kesely
Andrej Kesely

Reputation: 195573

Another solution:

For the purpose of sorting you can define custom str class where you override the __lt__ magic method (less than):

class reverse_cmp_string(str):
    def __lt__(self, other):
        return not str.__lt__(self, other)

sorted_persons = sorted(persons, key=lambda p: (p.age, reverse_cmp_string(p.name)))
print(sorted_persons)

Prints:

[(Charlie, 23), (Dave, 25), (Bob, 25), (Alice, 25)]

Upvotes: 4

Python used to have the cmp built-in function which did what you want.

There was also a keyword argument callled cmp for sort and sorted, which is what you could have used here, but it was removed in Python 3.0+ because the key was often more performant than cmp. Unfortunately you found the one where it quite isn't.

However there is a trick you could do here: you can use tuple comparison and you can swap the names, therefore:

def compare(p1, p2):
    a = p1.age, p2.name
    b = p2.age, p1.name

then you can just compare these two. There is also a trick to nicely produce -1, 0, 1 by using the fact that True and False are integers with values of 1 and 0 respectively:

    return (a > b) - (a < b)

if > and < are normally defined then maximum one side will result in True, with the other being False; True - False == 1 and False - True == -1, and False - False == 0.

Upvotes: 2

Kenneth Granahan
Kenneth Granahan

Reputation: 47

This snippet of code should do it. I ran it and the output looks right

sorted_persons = sorted(persons, key=lambda p: (-p.age, p.name), reverse=True)

Upvotes: 2

Related Questions