Core Python: Part 3

Dictionaries and Sets

Core Python: Part 3

Did you know? math.sin__name__ prints 'sin'

Check out the previous part: Core Python: Part 2

Dictionary:

Some languages call it hash and as we know a dictionary that is used to translate words from one language to another, basically has a key-value pair that has the same syntax in programming languages as well.

  1. Dictionaries are mutable

     my_dict = {"a": 1, "b": 2}
    
     # Adding a new key-value pair
     my_dict["c"] = 3
    
     # Updating the value of an existing key
     my_dict["b"] = 10
    
     # Removing a key-value pair using del
     del my_dict["b"]
    
     # Removing and returning a value using pop
     removed_value = my_dict.pop("a")
    
  2. Dictionary keys are hashable objects

    Hashable objects in Python include integers, floats, strings, tuples (if their elements are hashable), and some built-in types like frozensets. Lists, dictionaries, and other mutable objects are not hashable because their values can change after creation. Hence keys are immutable and unique (that is if we define the same key with two different values, the latest value will be retained).

    Why do we use hashable objects as keys?
    Because they allow the interpreter to quickly determine the storage location of an object.

     # Keys are integers (hashable)
     hashable_dict = {1: "one", 2: "two", 3: "three"} 
    
     # Lists are not hashable (raises TypeError)
     unhashable_dict = {[1, 2]: "list"}  
    
     # creating a dictionary with dict
     ordinal = dict([(1, 'First'), (2, 'Second'), (3, 'Third')])
    
  3. Dictionary Methods

    Dictionaries are iterable and hence can be used in loops. Along with this, we will learn about get(), keys(), values(), and items() methods. As we saw about KeyError in part 1, get() is used to avoid that as it simply prints None if the key does not exist in a dictionary if no optional message is provided.

    keys(), values() and items() methods return an iterable object without copying to a list as this is faster and saves memory. Since they are not list, they cannot be indexed or assigned but can be converted to list using list(). Same applies for items() and values() methods as well.

     my_dict = {"a": 1, "b": 2, "c": 3}
    
     # Using for loop to iterate over keys
     print("Keys:")
     for key in my_dict:
         print(key, my_dict[key])  # Output: a 1, b 2, c 3
    
     # keys() method
     my_dict.keys() # dict_keys(['a', 'b', 'c'])
     my_dict.keys()[0] # TypeError: 'dict_keys' object is not subscriptable
     list(my_dict.keys())[0] # Output: a
    
     # Using keys() method to iterate over keys
     for key in my_dict.keys():
         print(key)  # Output: a, b, c
    
     # Using items() method to iterate over key-value pairs
     print("\nItems() method:")
     for key, value in my_dict.items():
         print(key, value)  # Output: a 1, b 2, c 3
    
     # Using values() method to iterate over values
     print("\nValues() method:")
     for value in my_dict.values():
         print(value)  # Output: 1, 2, 3
    
     # Using get() method to access values
     print("\nUsing get() method:")
     print(my_dict.get("b"))  # Output: 2
     print(my_dict.get("x"))  # Output: None
     print(my_dict.get("x", "Not found"))  # Output: Not found
    
  4. Keyword arguments

    When we do not know how many arguments a function is going to receive, we use *args that collect extra positional arguments in a tuple.

    **kwargs allows a function to accept an arbitrary number of keyword arguments. It collects all the extra keyword arguments passed to the function into a dictionary.

     def my_function(x, *args): # 1 is assigned to x and remaining arguments to args
         for arg in args:
             print(arg)
    
     my_function(1, 2, 3)  # Output: 2, 3
    
     def my_function(st, country, **kwargs): 
     # Berlin is assigned to st and Germany is assigned to country and the rest to kwargs as dictionary elements
         for key, value in kwargs.items():
             print(f"{key}: {value}")
    
     my_function("Berlin", country="Germany" name="Alice", age=30, city="Wonderland")
     # Output: name: Alice, age: 30, city: Wonderland
    
  5. defaultdict

    It is not an in-built function but should be imported from the collections module of Python. It adds an additional benefit to the dictionary i.e. it helps to avoid KeyError and create dictionaries with default values for keys that do not yet exist.

     from collections import defaultdict
    
     # Create a defaultdict with a default value of int (0)
     my_dict = defaultdict(int)
    
     my_dict["a"] = 5
     my_dict["b"] = 10
    
     print(my_dict["a"])  # Output: 5
     print(my_dict["b"])  # Output: 10
     print(my_dict["c"])  # Output: 0 (default value for nonexistent key)
    
     # Create a defaultdict with a default value of list (empty list)
     my_dict = defaultdict(list)
    
     my_dict["a"].append(5)
     my_dict["b"].extend([10, 20])
    
     print(my_dict["a"])  # Output: [5]
     print(my_dict["b"])  # Output: [10, 20]
     print(my_dict["c"])  # Output: [] (default value for nonexistent key)
    

    Before beginning with sets, let's learn about one more error - AttributeError.

    An AttributeError in Python is raised when you try to access an attribute (a property or method) of an object that doesn't exist for that particular object.

     import math
    
     class MyClass:
         def __init__(self):
             self.value = 10
    
     obj = MyClass()
     print(obj.val)  # Raises AttributeError because 'val' attribute doesn't exist
     print(math.sine(30))  # Raises AttributeError because it's math.sin(), not math.sine()
    

    Sets:

    Why sets?

    • Iterable and mutable but the elements of sets are hashable.

    • Unique, hence remove the duplicate elements

    • Test membership of an element using in and not in operator

    • Supports the len() function

    • Determines all the Set Theory terms like Union, Intersection, Difference, Symmetric Difference, Cardinality, Subset, Proper Subset, Disjoint Sets

Why not sets?

They cannot be indexed or sliced and cannot be used as dictionary keys.

    my_set = {1, 2, 3, 4, 5}

    # Trying to access elements by index or slice
    print(my_set[0])  # Raises TypeError: 'set' object is not subscriptable
    print(my_set[1:3])  # Raises TypeError: 'set' object is not subscriptable

Note: Sets determine the uniqueness by value and not by type. [1 == 1.0 is True], here though 1 and 1.0 are of integer and float types their values are equal hence set removes one of them if we try to add both to the set.

    s = {2, 9}
    s.add(1)
    s.add(1.0)
    print(s) # Output: {1, 2, 9} 1.0 is not added as 1 already exists

Methods of sets

    # 1. add - Adds the specified element to the set if it is not already present.
    my_set = {1, 2, 3}
    my_set.add(4)

    # 2. remove - Removes the specified element from the set. Raises a KeyError if the element is not found.
    my_set.remove(2)

    # 3. discard - Removes the specified element from the set if it is present. Does not raise an error and does nothing if the element is not found.
    my_set.discard(2)

    # 4. pop - Removes and returns an arbitrary element from the set
    popped_element = my_set.pop()

    # 5. clear - Removes all elements from the set, leaving it empty.
    my_set.clear()

frozenset:

As we learned, sets are mutable and cannot be used as keys in dictionaries there is a frozenset object that is immutable and hashable and hence can be used as dictionary keys and members of the set.

    # Creating a frozenset
    my_frozenset = frozenset([1, 2, 3])

    # Accessing elements of a frozenset
    for element in my_frozenset:
        print(element)

    # Trying to modify a frozenset (raises an error)
    my_frozenset.add(4)  
    # Raises AttributeError: 'frozenset' object has no attribute 'add' 
    # because frozenset is immutable
Note: The main difference between immutable and hashable objects is 
immutability refers to an object's inability to change its value after 
creation, while hashability refers to an object's ability to be used as a 
key in a dictionary or an element in a set. In Python, all immutable 
objects are hashable, meaning that they can be used as keys in dictionaries
 or elements in sets. The reason for this is that hashability is closely 
tied to immutability; objects that cannot change their state after creation
 are suitable for being hashed. Therefore, there are no examples of 
immutable objects that are not hashable in Python's built-in data types.

Check out the next part: Core Python: Part 4