Escribir código legible para VBA:patrón Try*
Últimamente, me he encontrado usando el Try
patrón cada vez más. Realmente me gusta este patrón porque hace que el código sea mucho más legible. Esto es especialmente importante cuando se programa en un lenguaje de programación maduro como VBA, donde el manejo de errores está entrelazado con el flujo de control. En general, encuentro que los procedimientos que se basan en el manejo de errores como un flujo de control son más difíciles de seguir.
Escenario
Comencemos con un ejemplo. El modelo de objetos DAO es un candidato perfecto debido a cómo funciona. Mira, todos los objetos DAO tienen Properties
colección, que contiene Property
objetos. Sin embargo, cualquiera puede agregar propiedades personalizadas. De hecho, Access agregará varias propiedades a varios objetos DAO. Por lo tanto, es posible que tengamos una propiedad que podría no existir y debemos manejar tanto el caso de cambiar el valor de una propiedad existente como el caso de agregar una nueva propiedad.
Usemos Subdatasheet
propiedad como ejemplo. De forma predeterminada, todas las tablas creadas a través de la interfaz de usuario de Access tendrán la propiedad establecida en Auto
, pero es posible que no queramos eso. Pero si tenemos tablas que se crean en código o de alguna otra manera, es posible que no tenga la propiedad. Entonces podemos comenzar con una versión inicial del código para actualizar la propiedad de todas las tablas y manejar ambos casos.
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" On Error GoTo ErrHandler Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. Set prp = tdf.Properties(SubDatasheetPropertyName) If prp.Value <> NewValue Then prp.Value = NewValue End If End If End If Continue: Next ExitProc: Exit Sub ErrHandler: If Err.Number = 3270 Then Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp Resume Continue End If MsgBox Err.Number & ": " & Err.Description Resume ExitProc End Sub
El código probablemente funcionará. Sin embargo, para entenderlo, probablemente tengamos que diagramar algún diagrama de flujo. La línea Set prp = tdf.Properties(SubDatasheetPropertyName)
podría arrojar potencialmente un error 3270. En este caso, el control salta a la sección de manejo de errores. Luego creamos una propiedad y luego reanudamos en un punto diferente del ciclo usando la etiqueta Continue
. Hay algunas preguntas...
- ¿Qué sucede si se eleva 3270 en alguna otra línea?
- Supongamos que la línea
Set prp =...
no lanza error 3270 pero en realidad algún otro error? - ¿Qué sucede si mientras estamos dentro del controlador de errores, ocurre otro error al ejecutar
Append
? oCreateProperty
? - ¿Debería esta función mostrar un
Msgbox
? ? Piense en funciones que se supone que funcionan en algo en nombre de formularios o botones. Si las funciones muestran un cuadro de mensaje, salga normalmente, el código de llamada no tiene idea de que algo salió mal y podría continuar haciendo cosas que no debería estar haciendo. - ¿Puedes mirar el código y entender lo que hace inmediatamente? No puedo. Tengo que mirarlo con los ojos entrecerrados, luego pensar en lo que debería suceder en caso de error y trazar mentalmente el camino. Eso no es fácil de leer.
Añadir una HasProperty
procedimiento
¿Podemos hacerlo mejor? ¡Sí! Algunos programadores ya reconocen el problema con el uso del manejo de errores como lo ilustré y abstrajeron sabiamente esto en su propia función. Aquí hay una versión mejorada:
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. If Not HasProperty(tdf, SubDatasheetPropertyName) Then Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp Else If tdf.Properties(SubDatasheetPropertyName) <> NewValue Then tdf.Properties(SubDatasheetPropertyName) = NewValue End If End If End If End If Next End Sub Public Function HasProperty(TargetObject As Object, PropertyName As String) As Boolean Dim Ignored As Variant On Error Resume Next Ignored = TargetObject.Properties(PropertyName) HasProperty = (Err.Number = 0) End Function
En lugar de mezclar el flujo de ejecución con el manejo de errores, ahora tenemos una función HasFunction
que abstrae perfectamente la comprobación propensa a errores de una propiedad que puede no existir. Como consecuencia, no necesitamos el flujo complejo de manejo/ejecución de errores que vimos en el primer ejemplo. Esta es una gran mejora y hace que el código sea algo legible. Pero…
- Tenemos una rama que usa la variable
prp
y tenemos otra rama que usatdf.Properties(SubDatasheetPropertyName)
que de hecho se refiere a la misma propiedad. ¿Por qué nos repetimos con dos formas diferentes de hacer referencia a la misma propiedad? - Estamos manejando mucho la propiedad. El
HasProperty
tiene que manejar la propiedad para averiguar si existe y luego simplemente devuelve unBoolean
resultado, dejando que el código de llamada vuelva a intentar obtener la misma propiedad para cambiar el valor. - Del mismo modo, estamos manejando el
NewValue
mas que necesario. O lo pasamos enCreateProperty
o establece elValue
propiedad de la propiedad. - El
HasProperty
La función asume implícitamente que el objeto tieneProperties
y lo llama enlazado en tiempo de ejecución, lo que significa que es un error de tiempo de ejecución si se le proporciona un tipo de objeto incorrecto.
Usar TryGetProperty
en cambio
¿Podemos hacerlo mejor? ¡Sí! Ahí es donde tenemos que mirar el patrón Try. Si alguna vez ha programado con .NET, probablemente haya visto métodos como TryParse
donde en lugar de generar un error en caso de falla, podemos establecer una condición para hacer algo para el éxito y otra cosa para la falla. Pero lo más importante, tenemos el resultado disponible para el éxito. Entonces, ¿cómo mejoraríamos HasProperty
? ¿función? Por un lado, debemos devolver la Property
objeto. Probemos este código:
Public Function TryGetProperty( _ ByVal SourceProperties As DAO.Properties, _ ByVal PropertyName As String, _ ByRef OutProperty As DAO.Property _ ) As Boolean On Error Resume Next Set OutProperty = SourceProperties(PropertyName) If Err.Number Then Set OutProperty = Nothing End If On Error GoTo 0 TryGetProperty = (Not OutProperty Is Nothing) End Function
Con pocos cambios, hemos obtenido pocas victorias importantes:
- El acceso a
Properties
ya no está encuadernado en tiempo de ejecución. No tenemos que esperar que un objeto tenga una propiedad llamadaProperties
y es deDAO.Properties
. Esto se puede verificar en tiempo de compilación. - En lugar de solo un
Boolean
resultado, también podemos obtener laProperty
recuperada objeto, pero sólo en el éxito. Si fallamos, laOutProperty
el parámetro seráNothing
. Todavía usaremos elBoolean
resultado para ayudar a configurar el flujo ascendente como verá en breve. - Nombrando nuestra nueva función con
Try
prefijo, estamos indicando que esto está garantizado para no arrojar un error en condiciones normales de funcionamiento. Obviamente, no podemos evitar errores de falta de memoria o algo así, pero en ese punto, tenemos problemas mucho mayores. Pero en condiciones normales de funcionamiento, hemos evitado enredar nuestro manejo de errores con el flujo de ejecución. El código ahora se puede leer de arriba a abajo sin saltar hacia adelante o hacia atrás.
Tenga en cuenta que, por convención, prefijo la propiedad "out" con Out
. Eso ayuda a aclarar que se supone que debemos pasar la variable a la función sin inicializar. También esperamos que la función inicialice el parámetro. Eso quedará claro cuando veamos el código de llamada. Entonces, configuremos el código de llamada.
Código de llamada revisado usando TryGetProperty
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. If TryGetProperty(tdf, SubDatasheetPropertyName, prp) Then If prp.Value <> NewValue Then prp.Value = NewValue End If Else Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp End If End If End If Next End Sub
El código ahora es un poco más legible con el primer patrón Try. Hemos logrado reducir el manejo del prp
. Tenga en cuenta que pasamos el prp
variable en el prp
se inicializará con la propiedad que queremos manipular. De lo contrario, el prp
queda Nothing
. Entonces podemos usar CreateProperty
para inicializar el prp
variables.
También invertimos la negación para que el código sea más fácil de leer. Sin embargo, realmente no hemos reducido el manejo de NewValue
parámetro. Todavía tenemos otro bloque anidado para comprobar el valor. ¿Podemos hacerlo mejor? ¡Sí! Agreguemos otra función:
Agregar TrySetPropertyValue
procedimiento
Public Function TrySetPropertyValue( _ ByVal SourceProperty As DAO.Property, _ ByVal NewValue As Variant_ ) As Boolean If SourceProperty.Value = PropertyValue Then TrySetPropertyValue = True Else On Error Resume Next SourceProperty.Value = NewValue On Error GoTo 0 TrySetPropertyValue = (SourceProperty.Value = NewValue) End If End Function
Como estamos garantizando que esta función no arrojará un error al cambiar el valor, la llamamos TrySetPropertyValue
. Más importante aún, esta función ayuda a encapsular todos los detalles sangrientos que rodean el cambio del valor de la propiedad. Tenemos una manera de garantizar que el valor es el valor que esperábamos que fuera. Veamos cómo se cambiará el código de llamada con esta función.
Código de llamada actualizado usando ambos TryGetProperty
y TrySetPropertyValue
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. If TryGetProperty(tdf, SubDatasheetPropertyName, prp) Then TrySetPropertyValue prp, NewValue Else Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp End If End If End If Next End Sub
Hemos eliminado un If
completo cuadra. Ahora podemos simplemente leer el código e inmediatamente estamos tratando de establecer un valor de propiedad y si algo sale mal, simplemente seguimos adelante. Eso es mucho más fácil de leer y el nombre de la función es autodescriptivo. Un buen nombre hace que sea menos necesario buscar la definición de la función para entender lo que está haciendo.
Crear TryCreateOrSetProperty
procedimiento
El código es más legible pero todavía tenemos eso Else
bloque creando una propiedad. ¿Podemos hacerlo mejor aún? ¡Sí! Pensemos en lo que necesitamos lograr aquí. Tenemos una propiedad que puede o no existir. Si no es así, queremos crearlo. Ya sea que exista o no, necesitamos que se establezca en un cierto valor. Entonces, lo que necesitamos es una función que cree una propiedad o actualice el valor si ya existe. Para crear una propiedad, debemos llamar a CreateProperty
que desafortunadamente no está en las Properties
sino más bien diferentes objetos DAO. Por lo tanto, debemos enlazar tarde usando Object
tipo de datos. Sin embargo, todavía podemos proporcionar algunas comprobaciones de tiempo de ejecución para evitar errores. Vamos a crear una TryCreateOrSetProperty
función:
Public Function TryCreateOrSetProperty( _ ByVal SourceDaoObject As Object, _ ByVal PropertyName As String, _ ByVal PropertyType As DAO.DataTypeEnum, _ ByVal PropertyValue As Variant, _ ByRef OutProperty As DAO.Property _ ) As Boolean Select Case True Case TypeOf SourceDaoObject Is DAO.TableDef, _ TypeOf SourceDaoObject Is DAO.QueryDef, _ TypeOf SourceDaoObject Is DAO.Field, _ TypeOf SourceDaoObject Is DAO.Database If TryGetProperty(SourceDaoObject.Properties, PropertyName, OutProperty) Then TryCreateOrSetProperty = TrySetPropertyValue(OutProperty, PropertyValue) Else On Error Resume Next Set OutProperty = SourceDaoObject.CreateProperty(PropertyName, PropertyType, PropertyValue) SourceDaoObject.Properties.Append OutProperty If Err.Number Then Set OutProperty = Nothing End If On Error GoTo 0 TryCreateOrSetProperty = (OutProperty Is Nothing) End If Case Else Err.Raise 5, , "Invalid object provided to the SourceDaoObject parameter. It must be an DAO object that contains a CreateProperty member." End Select End Function
Algunas cosas a tener en cuenta:
- Pudimos construir sobre el anterior
Try*
función que definimos, que ayuda a reducir la codificación del cuerpo de la función, lo que le permite centrarse más en la creación en caso de que no exista tal propiedad. - Esto es necesariamente más detallado debido a las comprobaciones de tiempo de ejecución adicionales, pero podemos configurarlo para que los errores no alteren el flujo de ejecución y aún podamos leer de arriba a abajo sin saltos.
- En lugar de lanzar un
MsgBox
de la nada, usamosErr.Raise
y devolver un error significativo. El manejo de errores real se delega al código de llamada, que luego puede decidir si mostrar un cuadro de mensaje al usuario o hacer otra cosa. - Debido a nuestro manejo cuidadoso y siempre que el
SourceDaoObject
el parámetro es válido, toda la ruta posible garantiza que cualquier problema con la creación o el establecimiento del valor de una propiedad existente se manejará y obtendremos unfalse
resultado. Eso afecta el código de llamada como veremos en breve.
Versión final del código de llamada
Actualicemos el código de llamada para usar la nueva función:
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. TryCreateOrSetProperty tdf, SubDatasheetPropertyName, dbText, NewValue End If End If Next End Sub
Esa fue una gran mejora en la legibilidad. En la versión original, tendríamos que analizar una serie de If
bloques y cómo el manejo de errores altera el flujo de ejecución. Tendríamos que averiguar qué estaba haciendo exactamente el contenido para concluir que estamos tratando de obtener una propiedad o crearla si no existe y establecerla en un valor determinado. Con la versión actual, todo está en el nombre de la función, TryCreateOrSetProperty
. Ahora podemos ver lo que se espera que haga la función.
Conclusión
Quizás se esté preguntando, “pero agregamos muchas más funciones y muchas más líneas. ¿No es mucho trabajo? Es cierto que en esta versión actual, definimos 3 funciones más. Sin embargo, puede leer cada función de forma aislada y aun así entender fácilmente lo que debe hacer. También viste que TryCreateOrSetProperty
la función podría acumularse en los otros 2 Try*
funciones Eso significa que tenemos más flexibilidad para ensamblar la lógica.
Entonces, si escribimos otra función que hace algo con la propiedad de los objetos, no tenemos que escribirla por completo ni copiar y pegar el código de la EditTableSubdatasheetProperty
original. en la nueva función. Después de todo, la nueva función podría necesitar algunas variantes diferentes y, por lo tanto, requerir una secuencia diferente. Finalmente, tenga en cuenta que los verdaderos beneficiarios son el código de llamada que necesita hacer algo. Queremos mantener el código de llamada en un nivel bastante alto sin perder detalles que pueden ser perjudiciales para el mantenimiento.
También puede ver que el manejo de errores se simplifica significativamente, aunque usamos On Error Resume Next
. Ya no necesitamos buscar el código de error porque, en la mayoría de los casos, solo nos interesa saber si tuvo éxito o no. Más importante aún, el manejo de errores no cambió el flujo de ejecución donde tiene algo de lógica en el cuerpo y otra lógica en el manejo de errores. Esta última es una situación que definitivamente queremos evitar porque si hay un error en el controlador de errores, el comportamiento puede ser sorprendente. Es mejor evitar que eso sea una posibilidad.
Se trata de abstracción
Pero el puntaje más importante que ganamos aquí es el nivel de abstracción que podemos alcanzar ahora. La versión original de EditTableSubdatasheetProperty
contenía una gran cantidad de detalles de bajo nivel sobre el objeto DAO que realmente no se trata del objetivo principal de la función. Piense en los días en los que ha visto un procedimiento que tiene cientos de líneas de largo con bucles o condiciones profundamente anidados. ¿Quieres depurar eso? Yo no.
Entonces, cuando veo un procedimiento, lo primero que realmente quiero hacer es separar las partes en su propia función, para poder elevar el nivel de abstracción de ese procedimiento. Al obligarnos a impulsar el nivel de abstracción, también podemos evitar grandes clases de errores en los que la causa es que un cambio en una parte del megaprocedimiento tiene ramificaciones no deseadas para las otras partes de los procedimientos. Cuando llamamos a funciones y pasamos parámetros, también reducimos la posibilidad de que efectos secundarios no deseados interfieran con nuestra lógica.
Por eso me encanta el patrón "Prueba*". Espero que también lo encuentre útil para sus proyectos.