Object oriented programming (OOP) in LabVIEW has many benefits, but for those who've learned LabVIEW apart from the OOP paradigm, the transition can be daunting. I wanted to help bridge the gap by providing some high level definitions of terms, explanations of OOP concepts, and show a "before and after OOP" application attached at the bottom of this document. These OOP principles help facilitate designing software that is scalable, modular, extensible, and reusable.
I'll start with a brief description of the three major concepts of OOP:
Loosely defined, encapsulation means "the practice of hiding the unimportant details of how a software module accomplishes its task". Of course, the details are important to you as the programmer of the software module. But the user of the software module often doesn't need to know how the task is accomplished.
We often inadvertently create a set of VIs designed to work on a specific cluster of data, usually a Type Def, thus accidentally forming a software module. Intentionally applying the encapsulation philosophy to the data means we would deliberately and strictly design a set VIs that get / set values of the cluster and / or operate on the cluster. Only these VIs would be able to interact with the cluster. If the cluster is accessed outside of one of the "sanctioned VIs", we want LabVIEW to break the run arrow. This strict enforcement of data access and manipulation helps to prevent a code module from being used outside its intended design.
Encapsulation can not only be applied to the data, but also to the module's VIs. We've all written VIs that support the overall goal of a software module, but aren't meant to be called in any way other than as a subVI in the software module they are supporting. Without applying encapsulation, the support VIs could be reused by other developers. Then the original author is obligated to not change those subVI's behavior for fear of breaking unintended clients. Another way to think of encapsulation is the prevention of establishing dependant relationships.
Carefully considering how the data of your software module is accessed and what VIs are allowed to be used outside of the software module helps prevent unintended dependency's from forming (aka coupling) and promotes cohesion.
Range checking / data validation is just one great demonstration of encapsulation's value. For example, if you're creating a data acquisition application that also logs data to disk, you may want to allow the user to specify the save location of the log file. Your code may look like:
But you may also want to restrict the file's location to a specific portion of the hard drive. Without using encapsulation, you'd need to keep track of all locations in the application where the path could be updated and then perform some sort of check at each location. This method is prone to error because a developer might miss places in the code where the path is set, or simply forget to check at all. These oversights are magnified when the code is passed off to another developer. To help solve these problems consider making a subVI such as the following:
This subVI would be the means by which the "Log File Path" was set (a bundle by name updates the cluster). It would also be responsible for enforcing the rules for "Log File Path", namely that files can only be written to the P:\ drive. We've consolidated the checking logic into this one VI. But without the encapsulation features of LVOOP, a developer is free to simply bundle and unbundle at will outside of this VI. However, with OOP, we can direct LabVIEW to associate a VI with the Logging Module Data such that the run arrow will be broken if the Type Def is accessed outside of the sanctioned VI (the association process is described later in the section "Associating a Type Def with VIs - a.k.a. How to create a class").
Now our logging application looks like:
We've directed LabVIEW to associate the Set Path.vi and Get Path.vi with the Logging Module Data so that only these two VIs can be used to access the "Log File Path" member of the Type Def.
Another example of encapsulation traditionally practiced is the use of LabVIEW Functional Global Variables (a.k.a "action engines"). By design, the cluster never appears on the block diagram outside of the action engine. It's contained in an uninitialized shift register, preventing unauthorized or ill defined interactions with the data.
There are a number of articles to help explain actions engines:
Clearly defining access to your code module's data via a set of top level VIs frees a developer from the burden of tracking interaction locations within the code module (such as the logging example above) since LabVIEW breaks the run arrow with each incorrect interaction. It also prevents unintentional coupling across software modules by defining a set of top level VIs designed to interact with the module. In addition, by providing a set of VIs to act on the data, you create an intuitive interface for anyone who may want to use your code module. Thus, the functions themselves become the documentation.
Inheritance, loosely defined, means that data (Type Def) and functionality (set of VIs) in a code module can be used as a foundation for other related code modules. Another way to think of it: inheritance allows you to build up functionality of a module by building on previous work, one "layer" at a time. Each layer gets all of the functionality of the one beneath it, but can then be edited and customized, leaving the original layer alone. The newly customized module can then itself be used as a foundation for further customization. The primary advantage of inheritance is code reuse and appropriate separation between each module. Consider the following graphic:
Each colored rectangle represents a code module that does "something" with some input unique to itself. (I'm using the familiar math notation for a function to aid in the demonstration.) The base layer is a function named "F" that takes in a parameter, x, and divides it by two. I then want to build two other functions with F at their core, each with their own additional behavior and parameters. Using inheritance define two new functions, G and H, each with their own input parameter, y and z respectively. Because G and H inherit from F, they each get F's functionality and parameters "for free", but can then define their own behavior and specify their own parameters. In G's case, it calls the base layer, F, and then adds y to it. Therefore, executing G requires an"x" parameter and a "y" parameter. In H's case it calls the base layer, F, and then subtracts z. Therefore, executing H requires an "x" and a "z". To further demonstrate the concept, I define a function, I, which calls G which calls F. Therefore to execute I requires an "x", a "y" and a "j".
This looks and feels very similar to a VI hierarchy where each subVI is represented by a function (F, G, H, I). By changing the behavior of F, all of the layers built on top would automatically get the new behavior of F because they inherit from, a.k.a. built on top of, F. But the major difference between a VI hierarchy and inheritance is the parameters for each function. Although we can easily create a VI hierarchy today, there is no way to create independent data hierarchies. Put another way, because H calls F, there is no way for H to be "ignorant" of y (used by G). That unnecessary "knowledge" leads to high coupling and low cohesion. Inheritance solves this problem by allowing independent data hierarchies.
As an example, let's go back to the data logger mentioned above. There are many file formats to choose from, each with specific advantages and disadvantages. For example, some file formats are human readable but have slower performance (like a text file) whereas other file types will require a file reader (such as a LabVIEW data log) but have better performance. We decide to create a single API that can perform both actions and abstract away the details. (Abstraction means hiding away all but the details relevant to the task, in this case saving data to disk).The common element to all logging types is a path to a file. So our base layer would have a Type Def that contains a path. Let's say that we wanted to be able to write a TDMS as well as a Binary file. Traditionally, we would put all of the information necessary for both TDMS and Binary in the same cluster.
Then we would put a case statement in each VI to operate on either file format so that the same VI could be used for both TDMS and the Binary type. We would also pass in a command to each subVI so they would know which file format to write.
In this scenario we no longer have separate and clearly defined VIs for TDMS and Binary files. They've been merged together into a single set of VIs and the cohesion has decreased. Further, the cluster itself becomes a "dumping ground" for the parameters across all of the VIs.
Here's the problem with the "dumping ground" approach: the number of cases in each case structure will increase as different types of information creep into the Logging Module Data. What happens when we want to add a new file type? Or what happens when we start specifying file type specific information such as "big-endian". For each new ability or each new file type, the cluster grows, the original VIs need to be edited, and then revalidated. There's also a high likelihood that once the code is passed along to another developer, they will add more information to the cluster beyond the original intent. As time goes on cohesion decreases and coupling increases making the logging module difficult to support and costly for feature addition.
Inheritance in OOP solves this problem. Instead of adding information to one main cluster, we create a base layer for "log", containing a path constant. Then inherit once for the TDMS type and then again for the Binary type. Because we inherited from the "log" layer, both the Binary and TDMS layers automatically get a "path" constant in their Type Defs, but each new layer is "ignorant" of the other, thus preserving cohesion and preventing coupling. Now we can add new data to each layer's data without the layers affecting each other. Consequently, two new Type Defs are created that look like the following:
But now that we have two Type Defs, how can can they both be passed to the logging functions? A benefit of inheritance in LabVIEW is: different data types can flow along the same wire, so long as that wire is from a common layer. Now that the data type on the wire can change at run time, won't that break the VI that uses the data? Polymorphism, the third facet of OOP, is the answer to that question.
In the traditional LabVIEW programming paradigm, we already have the notion of "polymorphic VIs". A polymorphic VI is a single VI node on the block diagram that can change behavior depending on the data you wire into one of its terminals at edit time (when the code isn't running), or depending on which instance you select from the ring selector at edit time.
The DAQmx Create channel is a well known example of a polymorphic VI. DAQmx Create Channel.vi allows you to create a Voltage, Current, Force, etc channel depending on the selection from the ring selector. However, once the selection is made and the program is running, that selection cannot be changed. The "Variant to Data" primitive is another example of a polymorphic VI. Although it has no ring selector, the "data" output will change depending on what's wired into the "type" input. Again, this change in behavior is all at edit time.
By contrast, with OOP in LabVIEW, polymorphism means "the function to execute will change depending on the data type passed to it at run time". This terminology can be confusing. In order to clarify the edit time vs. run time difference in behavior, LabVIEW OOP polymorphic VIs are called "dynamic dispatch" VIs. The term "dynamic" refers run time versus edit time, and dispatch referrers to the change in functionality. Literally, a different VI will execute depending on which Type Def is passed to the VI at run time. The VI node on the block diagram acts like a place holder, similar to the Call by Reference node. The VI that will actually execute at run time is determined once that placeholder receives the Type Def at run time. A benefit of inheritance in LabVIEW is: different data types can flow along the same wire, so long as that wire is from a common layer. This property is described in detail in "Demonstration of Polymorphism" below.
As a result of polymorphism, a single VI node on the block diagram can have many behaviors. The behavior depends on which Type Def is passed to it at run time. The nature of the dynamic dispatch VI node allows us to separate and organize functionality and preserve high cohesion and low coupling.
Continuing with our logging example, the two children layers, TDMS and Binary, inherit from the common "logging" layer that has a path constant in its cluster Type Def. Then each child layer adds its own unique information and, therefore, are different data types. However, again, both came from the common base layer. To reiterate, different data types can flow along the same wire, so long as that wire is of a common base layer. In other words, both a TDMS Type Def and a Binary Type Def can be passed on the same wire so long as that wire is of the "log" type. Since both a TDMS and Binary Type Def can be passed on the same log wire, the "write" function can dynamically dispatch the correct version of write that matches with the data passed to it.
It is important to note that the notion of designing VIs around a data type, and that the data type on a wire determines what functions execute is the paradigm shift between OOP and classic LabVIEW programming.
OOP Class Formal Definition
At this stage I need to pause and give the formal definition of a LabVIEW OOP class. Thus far, I've been using the terminology "body of code", "code module", "cluster", "layers", and "type def". To reiterate, the code module is a set of VIs designed to accomplish a single task. In our example so far the task has been to write to a log file. The Type Def contains the data used by the code module to accomplish the task, in this case the path and file refnums.
As an analogy of "instance", when you drag the .ctl of a Type Def onto a block diagram, LabVIEW places an instance of that type def. Editing the original definition causes instances to update with the changes.
In the graphic below, the constants on the right side of the "=" objects of their respective classes. The traditional representation of a type def is on the left and the OOP representation is on the right:
(You'll notice that TDMS and Binary contain "Logging Module Data" in their Type Defs. TDMS and Binary both inherited from log, and therefore have log's data and abilities.)
Demonstration of Polymorphism
To demonstrate how polymorphism solves the problem of passing different data types to the same VI (the question posed at the bottom of the inheritance section) consider the following block diagram.
The "Log" wire type is passed between all three VIs, and Log is the parent of Binary and TDMS. The Binary and TDMS class each have a path in their data because they inherit from the Log class. Because they both inherit from Log, there is no need to re-implement how the path is set for the Binary and TDMS classes. To reiterate, both TDMS and Binary get the data and abilities of thier parent. In this case, the Write to Log File Base Path and Read Log File Base Path.
Now consider the diagram below.
Polymorphism allows us to invoke a different "Write" instance by changing the object that is wired in. The log file generated from this block diagram will be a binary file type because the Binary object is passed on the parent's wire to each VI. LabVIEW automatically selected the correct VI implementation because of the association between data type and functions (Binary Class and Binary's implementation of Write to Log File).
To change to the TDMS version, simply change the object passed on the wire. Again, LabVIEW automatically chooses the correct dynamic dispatch VI.
Both of the examples shown above are edit time changes. I placed two different objects on the block diagram and wired them into the "write Log File Base Path.vi". But to demonstrate the RUN TIME behavior of dynamic dispatch, see the following block diagram:
You'll note that the parent's implementation of the "Write to Log File.vi" function is shown on the block diagram. In this case, LabVIEW doesn't know which object will be passed to the "Write to Log File.vi" function at run time, so it displays the parent's implementation because it is common to both possible objects. Once the code runs and the user presses "Run Time Choice", the "true" selection will pass a TDMS object on the parents wire which will cause the TDMS class' "Write to Log File.vi" to execute.
Associating a Type Def with VIs - a.k.a. How to Create a Class
When creating classes in LabVIEW there are many options. The following articles from the online LabVIEW help documentation have all of the necessary steps.
Creating a LabVIEW Class - The step by step mechanics of creating a class
Changing the Inheritance of a LabVIEW Class - The step by step mechanics of creating the inheritance relationship between two classes.
Creating a Member VI in a LabVIEW Class - The step by step mechanics of creating the different types of member VIs of a class.
Creating LabVIEW Classes - An in depth discussion of OOP topics, such as Inheritance, Polymorphism and Encapsulation
An application written with OOP versus traditional programming in LabVIEW
I've been asked by numerous customers for an example application implemented using the traditional LabVIEW style versus the LabVIEW Object Oriented style. I decided to use the very well known "Continuous Measurement and Logging" sample project that ships with LabVIEW. It's designed to simultaneously acquire from hardware (simulated in this case), log the acquired data to file, update the graph of the front panel, and respond to user events, such as button presses. I've taken a very small section of the application, namely the file IO module, and replaced it with an OOP implementation.
You can see all of the edits I made by opening up the bookmark manager (View>>Bookmark Manager) and noting all of the C_Cilino_OOP hash tags. Double clicking on each tag will take you to the edit location. I've endeavored to change as little as possible to make the comparison as easy as possible. The VI Package installs the Classed Based Logging application to <LabVIEW>\examples\National Instruments\Class Based Logging.
Note, I've implemented two sub classes in the project and put them in an .lvlib. This .lvlib is designed to work beyond the scope of this sample project.
You'll note that the program is hard coded to select TDMS but can easily be changed:
Object Oriented Programming shifts a programmer's perspective from functions to data. This shift lends itself to better software design so that individual functions and code modules have low coupling and high cohesion. Encapsulation, inheritance, and polymorphism facilitate good software design so that the features in your applications will be more scalable, modular, extensible and reusable.