Python : circular nested classes with static typing

Question:

I use 2 classes to manage emails:

  1. one MailServer to handle the mail server itself (host, login, etc.)
  2. one EMail to store the parsed data from each email.

The MailServer class has a member which stores the n latest emails as a list of EMail objects. The EMail class has a reference to the parent MailServer such that we can call server operations (move, delete) within the scope of Email through proxy methods.

class EMail(object):
  def delete(self):
    self.mailserver.delete_email(self.uid)

  def __init__(self, email_content:bytes, mailserver):
    self.mailserver = mailserver

    #… parse email content, set self.uid



class MailServer(object):
  def delete_email(self, email_uid:int):
    # Delete email from server by uid
    
  def __init__(self, host, login, password):
    # … connect, login into a server, fetch emails

    self.emails = []
    for email in email_queue:
      self.emails.append(EMail(email_content, self))

I found this logic allows for efficient implementation, since everything is handled from the EMail instance through proxy methods that take care of dispatching the relevant data from emails (flags, IDs, etc.) when calling the underlying server operations.

But I’m a big fan of static typesetting introduced in Python 3.6 for I have lost too many hours troubleshooting edge effects of implicit type casting in Python, and now they also enable auto-completion features in IDE.

So I would like to statically set the type of the mailserver:MailServer in the arguments of EMail.__init__(). But of course, it yields a NameError: name 'MailServer' is not defined if I do that.

C/C++ have a way to prototype objects ahead of declaring them, is there a similar mechanism in Python ?

Asked By: Aurélien Pierre

||

Answers:

What you have right now, from a static typing perspective, is two classes that have a strong dependency on each other. An EMail cannot exist without a MailServer and a MailServer cannot exist without knowing what an EMail is. With that tight coupling, you either need to define the classes in the same module or break the dependency.

If the classes are in the same module, then the annotations future import will save you. Assuming you’re on Python 3.7 or newer, you can put

from __future__ import annotations

at the top of your file (above all of your other imports), and then types which refer to things defined later in the file will be accepted by type checkers and the Python runtime alike.

However, this sort of tight coupling is often a sign of brittle design. So if you want to break this dependency, or if your classes are defined in different files from each other, you’ll want to look into dependency inversion. Basically, ask yourself the following two questions:

  1. Is there a situation in which a MailServer could function with some other implementation of the EMail class?
  2. Is there a situation where this EMail class could work without a mail server?

Given that you’re attached to your proxy method implementation, I’m assuming the answer to (2), at least in your current coding style, is "no". However, I think you could make the MailServer type abstract.

Consider having MailServer be abstracted over the type of emails. Something like (just a mock-up, intended to be expanded for your use case),

class MessageLike(object):
  ...

class EMail(MessageLike):
  ...

_MessageType = TypeVar(bound=MessageLike)

class MailServer(Generic[_MessageType]):
  emails: List[_MessageType]

class EMailServer(MailServer[EMail]):
  ...

Now we have the following dependencies:

  • EMailServer depends on MailServer and EMail
  • MailServer depends on the generic type MessageLike
  • EMail depends on MessageLike as well, and presumably depends on MailServer in your proxy methods (but not on EMailServer, it should work with any MailServer[EMail])
  • MessageLike depends on none of the others

No cycles whatsoever, and our implementation is more generic.

Answered By: Silvio Mayolo
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.