Interfaces, Practically

A real world example using Active Directory lookups.

Interfaces, Practically

In an earlier article, I defined interfaces conceptually by using real-world examples from outside of programming.  Now let's discuss interfaces in the programming context.  More specifically, let's talk about interfaces from a VBA perspective.

Terminology

  • Interface class: a class module that defines the "contract" (the public variables and methods that the implementing class must implement)
  • Implementing class: a class module that implements (i.e., provides the code for) all the public variables and methods in the interface class

Importance of naming conventions

Interestingly, there is no Interface keyword in the VBA language.  Any class module can be an interface (so long as the class has no public variables or methods with underscores in their names).  This has some interesting applications with regard to testing, but that's beyond the scope of this article.

The important point I want to make here is that you should make it a habit to prefix all of your interface class modules with the letter "i".  Personally, I use a lower-case "i", but many others prefer a capital "I".  Pick one or the other and use it consistently.

Leave out the code

As I noted above, the VBA Language Specification places almost no restrictions on which class modules may act as interface classes.  In theory, you could write a class that Implements (almost) any existing class module. The existing class module would instantly become an interface class, at least from the perspective of the implementing class.  

This is not something I've ever done, but it occurs to me as I'm writing this that it could have some interesting applications.  That said, if you are setting out to write an interface, then all of the routines in your interface class--properties, functions, and methods--should contain no code.

Document the expected behavior

What I like to do in my interfaces is to include comments above or inside my procedures.  These comments describe the functionality that my interface class expects the implementing classes to provide.

Remember, our interface does not describe how the code should work.  The how is completely up to the implementing class.  Rather, our interface describes what the code should do.  The implementations are meant to be black boxes.  Given the same inputs, our implementations should (generally) produce the same outputs.

Practical Example: Active Directory Lookups

Here's an example.  This is my iActiveDirectoryService interface.  There are multiple ways to query Active Directory for information, including using WinNT or LDAP.  Our calling code doesn't really care how the information is retrieved, though.  Rather, it only cares about what is retrieved.

By using an interface to define this interaction, we leave ourselves the flexibility to provide multiple implementations.  In some environments, LDAP may be more reliable than WinNT, and vice versa.  If we write all of our code against the interface, then we get the benefit of compile-time checks with the flexibility of swapping out implementations at runtime.

Sample Code: Interface Class

If you look at the source code below for the iActiveDirectoryService class, you will notice that there is nothing in the code itself that identifies it as an interface.  That is why the naming convention is so important.

' ------------------------------------------------------
' Name: iActiveDirectoryService
' Kind: Interface
' Purpose: Contract for retrieving information from an Active Directory domain controller.
' Author: Mike Wolfe
' Date: 8/26/2018
' Notes - Either the FQDN or Domain Controller property must be set prior to using class methods
'       - Specifying the specific Domain Controller is usually more reliable than just the
'           domain name, at least for domains with just a single domain controller
'       - For domains with multiple domain controllers, specifying the domain name only (FQDN)
'           may actually be more reliable (untested)
' ------------------------------------------------------
Option Compare Database
Option Explicit

'--== Property accessors ==--
' fully qualified domain name (e.g., "contoso.local")
Public Property Get Fqdn() As String
End Property

Public Property Let Fqdn(Value As String)
End Property

' domain controller (e.g., "dc1.contoso.local")
Public Property Let DomainController(Value As String)
End Property

Public Property Get DomainController() As String
End Property
'============================

'Returns True if there is a user in the Active Directory domain with the given login
Public Function UserExists(Login As String) As Boolean
End Function

'Returns True if there is a group in the Active Directory domain with the given group name
Public Function GroupExists(GroupName As String) As Boolean
End Function

'Returns the Full Name of the user as stored in Active Directory or a zero-length string
'Notes: - Does not throw error if user does not exist in AD
'       - Will return a zero-length string if domain admins did not assign a full name to this login
'           - In other words, don't use a check for zero-length string as a proxy for User existence
'           - Use the .UserExists function to check if the user really does not exist
Public Function GetUserFullName(Login As String) As String
End Function

'Returns True if Login exists and is a member of the Group passed in
Public Function UserBelongsToGroup(Login As String, GroupName As String) As Boolean
End Function

'Returns a string with the list of group names to which the user belongs
Public Function ListGroupsForUser(Login As String, Optional Delimiter As String = ", ") As String
End Function

'Returns a string with the list of user names which comprise the group
Public Function ListUsersByGroup(GroupName As String, Optional Delimiter As String = ", ") As String
End Function

Sample Code: Implementing Class

Notice the line Implements iActiveDirectoryService at the top of the module below.  That is the magic line that turns iActiveDirectoryService into an interface class.  

Once we add the Implements line, the VBA IDE will update the dropdowns to include "iActiveDirectoryService" as an option on the left dropdown.  When we select that option, the right dropdown will populate with all of the public properties, methods, and functions that we defined in iActiveDirectoryService.  Before our code will compile, we must create corresponding properties, methods, and functions in our implementing class.  Note that there is no compile-time requirement that any of these routines actually does anything; they may contain no code at all.

Some notes about the implementing class

I use early-binding in my code below when creating Dictionary objects.  This requires a reference to the Microsoft Scripting Runtime.  I've never had an issue with that runtime missing from any user machine, so I include that reference in almost all my projects now.

Also, the Throw method is not something built into Access.  That's my own replacement function for the Err.Raise method.  Same with the Conc function and the LogErr function.

Finally, I make no promises about the reliability of the code below.  It's not one of my more well-worn modules, so there could be some bugs or other problems lurking in there.  This is an article on interfaces, after all, not Active Directory lookups.  Your mileage may vary.

' ------------------------------------------------------
' Name: svcAD_WinNT
' Kind: Class Module
' Purpose: Implementation of iActiveDirectoryService that uses WinNT for lookups.
' Author: Mike
' Date: 8/24/2018
' Notes: Uses WinNT
' References: https://docs.microsoft.com/en-us/windows/desktop/ADSI/winnt-schemaampaposs-mandatory-and-optional-properties
' ------------------------------------------------------
Option Compare Database
Option Explicit

Implements iActiveDirectoryService

Private Type typThisClass
    Fqdn As String   'Fully-qualified domain name (e.g., "waynecountypa.local")
    DomainController As String   'Querying the domain controller directly may (?) improve performance
    Users As Scripting.Dictionary   'Cache of Users for faster retrieval of repeated calls for info
    Groups As Scripting.Dictionary  'Cache of Groups for faster retrieval of repeated calls for info
End Type
Private this As typThisClass

Public Property Get iActiveDirectoryService_Fqdn() As String
    iActiveDirectoryService_Fqdn = this.Fqdn
End Property

Public Property Let iActiveDirectoryService_Fqdn(Value As String)
    this.Fqdn = Value
End Property

Public Property Let iActiveDirectoryService_DomainController(Value As String)
    this.DomainController = Value
End Property

Public Property Get iActiveDirectoryService_DomainController() As String
    iActiveDirectoryService_DomainController = this.DomainController
End Property

'Returns True if there is a user in the Active Directory domain with the given login
Public Function iActiveDirectoryService_UserExists(Login As String) As Boolean
    iActiveDirectoryService_UserExists = (Not GetUserObject(Login) Is Nothing)
End Function

'Returns True if there is a group in the Active Directory domain with the given group name
Public Function iActiveDirectoryService_GroupExists(GroupName As String) As Boolean
    iActiveDirectoryService_GroupExists = (Not GetGroupObject(GroupName) Is Nothing)
End Function

'Returns the Full Name of the user as stored in Active Directory or a zero-length string
'Notes: - Does not throw error if user does not exist in AD
'       - Will return a zero-length string if domain admins did not assign a full name to this login
'           - In other words, don't use a check for zero-length string as a proxy for User existence
'           - Use the .UserExists function to check if the user really does not exist
Public Function iActiveDirectoryService_GetUserFullName(Login As String) As String
    Dim User As Object
    Set User = GetUserObject(Login)
    If User Is Nothing Then Exit Function
    
    iActiveDirectoryService_GetUserFullName = User.FullName
End Function

'Returns True if Login exists and is a member of the Group passed in
Public Function iActiveDirectoryService_UserBelongsToGroup(Login As String, GroupName As String) As Boolean
    If Not Me.iActiveDirectoryService_UserExists(Login) Then Exit Function  'Returns False
    
    Dim Group As Object
    For Each Group In GetUserObject(Login).Groups
        If Not Group Is Nothing Then
            If Group.Name = GroupName Then
                iActiveDirectoryService_UserBelongsToGroup = True
                Exit Function
            End If
        End If
    Next Group
End Function

'Returns a string with the list of group names to which the user belongs
Public Function iActiveDirectoryService_ListGroupsForUser(Login As String, Optional Delimiter As String = ", ") As String
    If Not Me.iActiveDirectoryService_UserExists(Login) Then Exit Function
    
    Dim Group As Object, s As String
    For Each Group In GetUserObject(Login).Groups
        If Not Group Is Nothing Then s = Conc(s, Group.Name, Delimiter)
    Next Group
    iActiveDirectoryService_ListGroupsForUser = s
End Function

Public Function iActiveDirectoryService_ListUsersByGroup(GroupName As String, Optional Delimiter As String = ", ") As String
    If Not Me.iActiveDirectoryService_GroupExists(GroupName) Then Exit Function
    
    Dim User As Object, s As String
    For Each User In GetGroupObject(GroupName).Users
        If Not User Is Nothing Then s = Conc(s, User.Name, Delimiter)
    Next User
    iActiveDirectoryService_ListUsersByGroup = s
End Function

Private Function GetUserObject(Login As String) As Object
    If this.Users Is Nothing Then Set this.Users = New Scripting.Dictionary
    If Not this.Users.Exists(Login) Then
        Dim User As Object
        Set User = GetAdObject(Login, "user")
        this.Users.Add Login, User
    End If
    Set GetUserObject = this.Users(Login)
End Function

Private Function GetGroupObject(GroupName As String) As Object
    If this.Groups Is Nothing Then Set this.Groups = New Scripting.Dictionary
    If Not this.Groups.Exists(GroupName) Then
        Dim Group As Object
        Set Group = GetAdObject(GroupName, "group")
        this.Groups.Add GroupName, Group
    End If
    Set GetGroupObject = this.Groups(GroupName)
End Function

Private Function GetAdObject(ObjName As String, Optional ObjType As String = "") As Object

    Select Case ObjType
    Case "", "group", "user", "computer"
    Case Else: Throw "Invalid ObjType: {0}", ObjType
    End Select
    
    Dim DomainLookup As String
    If Len(this.DomainController) > 0 Then
        DomainLookup = this.DomainController
    Else
        DomainLookup = this.Fqdn
    End If
    
    Dim ObjectLookup As String
    If Len(ObjType) > 0 Then
        ObjectLookup = ObjName & "," & ObjType
    Else
        ObjectLookup = ObjName
    End If
    
    
    On Error GoTo Err_GetAdObject
    DoCmd.Hourglass True
    Set GetAdObject = GetObject("WinNT://" & DomainLookup & "/" & ObjectLookup)

Exit_GetAdObject:
    DoCmd.Hourglass False
    Exit Function
Err_GetAdObject:
    Select Case Err.Number
    Case -2147022675  'Automation error: the name could not be found
        '   - This error is raised when looking up a non-existent login
        '   - Use the .UserExists function to test for this situation
    Case 462    'The remote server machine does not exist or is unavailable
        LogErr Err, Errors, "svcAD_WinNT", "GetAdObject", , DomainLookup & " not available"
    Case Else
        LogErr Err, Errors, "svcAD_WinNT", "GetAdObject"
    End Select
    Resume Exit_GetAdObject
End Function

Sample Code: Usage

To tie a bow around everything, I should talk briefly about how we use interfaces in our calling code.  First, we declare an object variable and set its type to our interface class.  Then, we set that variable to an instance of our implementing class.  At this point, when we use IntelliSense, we will see all of the public routines of our interface class.

Declaring an object using our interface class

There is nothing in VBA that prevents us from setting our object's type to that of the implementing class.  When we do that, we will have access to the implemented functions and properties of the interface class, but with the interface's class name prefixed.  Additionally, any other public functions and properties of the implementing class will also be available.

Declaring an object using our implementing class directly

TypeOf operator

The TypeOf operator allows us to check at runtime whether an object implements a specific interface class.  I've used this feature to implement the Python repr() feature in VBA.  For demonstration purposes, here is a contrived example.

Sub TestADSvc()
    
    Dim ADSvc As iActiveDirectoryService
    Set ADSvc = New svcAD_WinNT
    
    Debug.Print String(60, "_")
    Debug.Print , "TypeName(ADSvc): "; TypeName(ADSvc)
    Debug.Print "TypeOf ADSvc Is iActiveDirectoryService: ", , TypeOf ADSvc Is iActiveDirectoryService
    Debug.Print "TypeOf ADSvc Is svcAD_WinNT: ", , TypeOf ADSvc Is svcAD_WinNT
    
    
    Dim AD_WinNT As New svcAD_WinNT
    
    Debug.Print vbNewLine; String(60, "_")
    Debug.Print , "TypeName(AD_WinNT): "; TypeName(AD_WinNT)
    Debug.Print "TypeOf AD_WinNT Is iActiveDirectoryService: ", TypeOf AD_WinNT Is iActiveDirectoryService
    Debug.Print "TypeOf AD_WinNT Is svcAD_WinNT: ", , TypeOf AD_WinNT Is svcAD_WinNT
    
End Sub
Procedure to test our Active Directory service interface and implementation classes
Results of running the TestADSvc() routine

Conclusion

Interfaces are a powerful yet underused feature of VBA.  I hope this article has inspired you in some small way to introduce or expand on this concept in your own code.

Further reading

Image by congerdesign from Pixabay

UPDATE (4/20/21): Changed the Python repr() link from https://www.programiz.com/python-programming/methods/built-in/repr to https://jobtensor.com/Python-Builtin-Functions-repr as the jobtensor site has no ads

All original code samples by Mike Wolfe are licensed under CC BY 4.0