Interfaces, Practically
A real world example using Active Directory lookups.
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.
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.
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.
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
- Interfaces and Implementation - Pearson Software Consulting
- Extensible Logging - Mathieu Guindon (of Rubberduck fame) on Code Review
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