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
prpy 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
HasPropertytiene que manejar la propiedad para averiguar si existe y luego simplemente devuelve unBooleanresultado, dejando que el código de llamada vuelva a intentar obtener la misma propiedad para cambiar el valor. - Del mismo modo, estamos manejando el
NewValuemas que necesario. O lo pasamos enCreatePropertyo establece elValuepropiedad de la propiedad. - El
HasPropertyLa función asume implícitamente que el objeto tienePropertiesy 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
Propertiesya no está encuadernado en tiempo de ejecución. No tenemos que esperar que un objeto tenga una propiedad llamadaPropertiesy es deDAO.Properties. Esto se puede verificar en tiempo de compilación. - En lugar de solo un
Booleanresultado, también podemos obtener laPropertyrecuperada objeto, pero sólo en el éxito. Si fallamos, laOutPropertyel parámetro seráNothing. Todavía usaremos elBooleanresultado para ayudar a configurar el flujo ascendente como verá en breve. - Nombrando nuestra nueva función con
Tryprefijo, 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
MsgBoxde la nada, usamosErr.Raisey 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
SourceDaoObjectel 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 unfalseresultado. 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.