Как запустить подпрограмму в функции `Create` и как сделать макет / заглушку / подделку для диаграммы` Series`? - PullRequest
2 голосов
/ 19 апреля 2020

Предисловие

Около 10 лет go Я начал рефакторинг и улучшение класса ChartSeries Джона Уокенбаха. К сожалению, кажется, что оригинал больше не доступен онлайн.

После блога Rubberduck в течение довольно долгого времени я пытаюсь улучшить свои навыки VBA. Но в прошлом я писал только - я полагаю, эксперты назвали бы это - «подобными богу процедурами» (из-за незнания лучше). Так что я довольно плохо знаком с классами, особенно с интерфейсами и фабриками.

Актуальные вопросы

Я пытаюсь реорганизовать весь класс, разделив его на несколько классов, также используя интерфейсы, а затем добавив модульные тесты. Для чтения частей формулы было бы достаточно получить Series.Formula и затем выполнить всю обработку. Так что было бы неплохо вызвать сабвуфер Run в функции Create. Но все, что я пытался сделать, провалилось. Таким образом, я в настоящее время запускаю Run во всех Get свойствах и c. (и проверьте, изменилась ли формула, и выйдите Run чем. Возможно ли это, и когда да, как?

Во-вторых, чтобы добавить модульные тесты - конечно, используя для них - В настоящее время я полагаюсь на real Charts / ChartObjects. Как создать заглушку / макет / фейк для Series? (Извините, я не знаю правильный термин.)

А вот упрощенная версия кода.

Заранее большое спасибо за любую помощь.

обычный модуль

'@Folder("ChartSeries")

Option Explicit

Public Sub ExampleUsage()

    Dim wks As Worksheet
    Set wks = ThisWorkbook.Worksheets(1)

    Dim crt As ChartObject
    Set crt = wks.ChartObjects(1)

    Dim srs As Series
    Set srs = crt.Chart.SeriesCollection(3)

    Dim MySeries As IChartSeries
    Set MySeries = ChartSeries.Create(srs)
    With MySeries
        Debug.Print .XValues.FormulaPart
    End With
End Sub

IChartSeries.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Function IsSeriesAccessible() As Boolean
End Function

Public Property Get FullFormula() As String
End Property

Public Property Get XValues() As ISeriesPart
End Property

'more properties ...

ChartSeries.cls

'@PredeclaredId
'@Exposed
'@Folder("ChartSeries")

Option Explicit
Implements IChartSeries

Private Type TChartSeries
   Series As Series
   FullSeriesFormula As String
   OldFullSeriesFormula As String
   IsSeriesAccessible As Boolean
   SeriesParts(eElement.[_First] To eElement.[_Last]) As ISeriesPart
End Type
Private This As TChartSeries

Public Function Create(ByVal Value As Series) As IChartSeries
'NOTE: I would like to run the 'Run' sub somewhere here (if possible)
   With New ChartSeries
      .Series = Value
      Set Create = .Self
   End With
End Function

Public Property Get Self() As IChartSeries
    Set Self = Me
End Property

Friend Property Let Series(ByVal Value As Series)
   Set This.Series = Value
End Property

Private Function IChartSeries_IsSeriesAccessible() As Boolean
   Call Run
   IChartSeries_IsSeriesAccessible = This.IsSeriesAccessible
End Function

Private Property Get IChartSeries_FullFormula() As String
   Call Run
   IChartSeries_FullFormula = This.FullSeriesFormula
End Property

Private Property Get IChartSeries_XValues() As ISeriesPart
   Call Run
   Set IChartSeries_XValues = This.SeriesParts(eElement.eXValues)
End Property

'more properties ...

Private Sub Class_Initialize()
   With This
      Dim Element As eElement
      For Element = eElement.[_First] To eElement.[_Last]
         Set .SeriesParts(Element) = New SeriesPart
      Next
   End With
End Sub

Private Sub Class_Terminate()
   With This
      Dim Element As LongPtr
      For Element = eElement.[_First] To eElement.[_Last]
         Set .SeriesParts(Element) = Nothing
      Next
   End With
End Sub

Private Sub Run()

   If Not GetFullSeriesFormula Then Exit Sub
   If Not HasFormulaChanged Then Exit Sub
   Call GetSeriesFormulaParts

End Sub

'(simplified version)
Private Function GetFullSeriesFormula() As Boolean

   GetFullSeriesFormula = False

   With This
'---
'dummy to make it work
.FullSeriesFormula = _
"=SERIES(Tabelle1!$B$2,Tabelle1!$A$3:$A$5,Tabelle1!$B$3:$B$5,1)"
'---
      .OldFullSeriesFormula = .FullSeriesFormula
      .FullSeriesFormula = .Series.Formula
   End With

   GetFullSeriesFormula = True

End Function

Private Function HasFormulaChanged() As Boolean
   With This
      HasFormulaChanged = (.OldFullSeriesFormula <> .FullSeriesFormula)
   End With
End Function

Private Sub GetSeriesFormulaParts()

   Dim MySeries As ISeriesFormulaParts
   '(simplified version without check for Bubble Chart)
   Set MySeries = SeriesFormulaParts.Create( _
         This.FullSeriesFormula, _
         False _
   )

   With MySeries
      Dim Element As eElement
      For Element = eElement.[_First] To eElement.[_Last] - 1
         This.SeriesParts(Element).FormulaPart = _
               .PartSeriesFormula(Element)
      Next
'---
'dummy which normally would be retrieved
'by 'MySeries.PartSeriesFormula(eElement.eXValues)'
This.SeriesParts(eElement.eXValues).FormulaPart = _
"Tabelle1!$A$3:$A$5"
'---
   End With

   Set MySeries = Nothing

End Sub

'more subs and functions ...

ISeriesPart.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Enum eEntryType
   eNotSet = -1
   [_First] = 0
   eInaccessible = eEntryType.[_First]
   eEmpty
   eInteger
   eString
   eArray
   eRange
   [_Last] = eEntryType.eRange
End Enum

Public Property Get FormulaPart() As String
End Property

Public Property Let FormulaPart(ByVal Value As String)
End Property

Public Property Get EntryType() As eEntryType
End Property

Public Property Get Range() As Range
End Property

'more properties ...

SeriesPart.cls

'@PredeclaredId
'@Folder("ChartSeries")
'@ModuleDescription("A class to handle each part of the 'Series' string.")

Option Explicit

Implements ISeriesPart

Private Type TSeriesPart
   FormulaPart As String
   EntryType As eEntryType
   Range As Range
   RangeString As String
   RangeSheet As String
   RangeBook As String
   RangePath As String
End Type
Private This As TSeriesPart

Private Property Get ISeriesPart_FormulaPart() As String
   ISeriesPart_FormulaPart = This.FormulaPart
End Property

Private Property Let ISeriesPart_FormulaPart(ByVal Value As String)
   This.FormulaPart = Value
   Call Run
End Property

Private Property Get ISeriesPart_EntryType() As eEntryType
   ISeriesPart_EntryType = This.EntryType
End Property

Private Property Get ISeriesPart_Range() As Range
   With This
      If .EntryType = eEntryType.eRange Then
         Set ISeriesPart_Range = .Range
      Else
'         Call RaiseError
      End If
   End With
End Property

Private Property Set ISeriesPart_Range(ByVal Value As Range)
   Set This.Range = Value
End Property

'more properties ...

Private Sub Class_Initialize()
   This.EntryType = eEntryType.eNotSet
End Sub

Private Sub Run()
   '- set 'EntryType'
   '- If it is a range then find the range parts ...
End Sub

'a lot more subs and functions ...

ISeriesParts.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Enum eElement
   [_First] = 1
   eName = eElement.[_First]
   eXValues
   eYValues
   ePlotOrder
   eBubbleSizes
   [_Last] = eElement.eBubbleSizes
End Enum

'@Description("fill me")
Public Property Get PartSeriesFormula(ByVal Element As eElement) As String
End Property

SeriesFormulaParts.cls

'@PredeclaredId
'@Exposed
'@Folder("ChartSeries")

Option Explicit

Implements ISeriesFormulaParts

Private Type TSeriesFormulaParts
   FullSeriesFormula As String
   IsSeriesInBubbleChart As Boolean
   WasRunCalled As Boolean

   SeriesFormula As String

   RemainingFormulaPart(eElement.[_First] To eElement.[_Last]) As String
   PartSeriesFormula(eElement.[_First] To eElement.[_Last]) As String
End Type
Private This As TSeriesFormulaParts

Public Function Create( _
   ByVal FullSeriesFormula As String, _
   ByVal IsSeriesInBubbleChart As Boolean _
      ) As ISeriesFormulaParts
'NOTE: I would like to run the 'Run' sub somewhere here (if possible)
   With New SeriesFormulaParts
      .FullSeriesFormula = FullSeriesFormula
      .IsSeriesInBubbleChart = IsSeriesInBubbleChart
      Set Create = .Self
   End With
End Function

Public Property Get Self() As ISeriesFormulaParts
    Set Self = Me
End Property

'@Description("Set the full series formula ('ChartSeries')")
Public Property Let FullSeriesFormula(ByVal Value As String)
   This.FullSeriesFormula = Value
End Property

Public Property Let IsSeriesInBubbleChart(ByVal Value As Boolean)
   This.IsSeriesInBubbleChart = Value
End Property

Private Property Get ISeriesFormulaParts_PartSeriesFormula(ByVal Element As eElement) As String
'NOTE: Instead of running 'Run' here, it would be better to run it in 'Create'
   Call Run
   ISeriesFormulaParts_PartSeriesFormula = This.PartSeriesFormula(Element)
End Property

'(replaced with a dummy)
Private Sub Run()

   If This.WasRunCalled Then Exit Sub
   'extract stuff from
   This.WasRunCalled = True

End Sub

'a lot more subs and functions ...

1 Ответ

2 голосов
/ 20 апреля 2020

Вы уже можете!

Public Function Create(ByVal Value As Series) As IChartSeries
   With New ChartSeries <~ With block variable has access to members of the ChartSeries class
      .Series = Value
      Set Create = .Self
   End With
End Function

... только, как свойства .Series и .Self, он должен быть Public членом ChartSeries interface / class (линия размыта в VBA, поскольку каждый класс имеет интерфейс по умолчанию / также является интерфейсом).

Idiomati c Назначение объекта

Примечание об этом свойстве :

Friend Property Let Series(ByVal Value As Series)
   Set This.Series = Value
End Property

Использование члена Property Let для Set ссылки на объект будет работать - но это уже не идиоматический c код VBA, как вы можете видеть в .Create функция:

      .Series = Value

Если мы читаем эту строку, не зная о характере свойства, это похоже на любое другое присвоение значения. Единственная проблема в том, что мы не присваиваем значение , а reference - и ссылки в VBA обычно выполняются с использованием ключевого слова Set. Если мы изменим Let для Set в определении свойства Series, нам нужно будет сделать это:

      Set .Series = Value

И это будет выглядеть гораздо проще, чем референсное присвоение! Без этого, по-видимому, происходит неявное принудительное приведение в исполнение, и это делает его неоднозначным кодом: VBA требует ключевое слово Set для ссылочных назначений, потому что любой данный объект может иметь свойство по умолчанию без параметров (например, как foo = Range("A1") неявно присваивает foo Value из Range).


Кэширование и обязанности

Теперь вернемся к методу Run - если это Сделано Public в классе ChartSeries, но не доступно в реализованном интерфейсе IChartSeries, тогда это член, который может быть вызван только из 1) экземпляра ChartSeries по умолчанию или 2) любой объектной переменной, которая имеет ChartSeries заявленный тип. А поскольку наш «клиентский код» работает на IChartSeries, мы можем защититься от 1 и отмахнуться от 2.

Обратите внимание, что ключевое слово Call является излишним, а метод Run на самом деле просто вытягивает метаданные из инкапсулированного объекта Series и кэширование его на уровне экземпляра - я бы дал ему имя, которое больше похоже на «refre sh кэшированные свойства», чем «запустить что-то».

Ваша догадка хороший вариант: Property Get должна быть простой функцией возврата без каких-либо побочных эффектов. Вызов метода, который сканирует объект и сбрасывает состояние экземпляра в методе доступа Property Get, приводит к побочным эффектам, что теоретически является запахом дизайна.

Если Run вызывается сразу после создания до * Функция 1069 * возвращает экземпляр, затем этот метод Run сводится к «анализу ряда и кэшированию некоторых метаданных, которые я буду использовать позже», и в этом нет ничего плохого: вызовите его из Create и удалите из Property Get средства доступа.

Результатом является объект, состояние которого доступно только для чтения и более надежно определено; в противоположность этому у вас теперь есть объект, состояние которого может быть не синхронизировано c с фактическим объектом Excel Series на листе: если код (или пользователь) изменяет объект Series после IChartSeries инициализируется, объект и его состояние устарели.

Одним из решений является go, чтобы вы могли определить, когда серия устарела, и убедиться, что кэш обновлен.

Другим решением было бы полное устранение проблемы, больше не кэшируя состояние - это означало бы одно из двух:

  1. Генерация графа объекта один раз при создании, эффективно Перенос ответственности за кэширование на вызывающего: вызывающий код получает «снимок» только для чтения для работы.

  2. Создание нового графа объектов из метаданных серии каждый раз, когда вызывающий код это нужно: эффективно переносит ответственность за кэширование на вызывающую сторону, что вовсе не плохая идея.

Создание вещей только для чтения удаляет многое сложности! Я бы go с первым вариантом.


В целом, код выглядит хорошим и чистым (хотя неясно, сколько было очищено для этого поста), и вы, кажется, поняли шаблон фабричного метода , использующий экземпляр по умолчанию и предоставляющий интерфейс фасада - kudos ! Именование в целом довольно хорошее (хотя «Выполнить» торчит IMO), и объекты выглядят так, как будто они имеют четкое, определенное назначение. Хорошая работа!


Модульное тестирование

В настоящее время я полагаюсь на реальные диаграммы / объекты ChartObjects. Как создать заглушку / макет / подделка для серии? (Извините, я не знаю правильный термин.)

В настоящее время вы не можете. Когда если / когда этот PR объединен , вы сможете макетировать интерфейсы Excel (и многое, многое другое) и писать тесты для ваших классов, которые вводят макет Excel.Series объект, который вы можете настроить для целей своих тестов ... но до тех пор именно здесь находится стена.

В то же время, лучшее, что вы можете сделать, это обернуть его собственным интерфейсом, и заглушка это. Другими словами, там, где есть шов между вашим кодом и объектной моделью Excel, мы проскальзываем интерфейс между ними: вместо того, чтобы брать объект Excel.Series, вы бы взяли какой-то ISeriesWrapper, а затем реальный код будет использовать ExcelSeriesWrapper, который работает на Excel.Series, а тестовый код может использовать StubSeriesWrapper, свойства которого возвращают либо жестко запрограммированные значения, либо значения, настроенные тестами: код, который работает в стыке между Excel библиотека и ваш проект не могут быть протестированы - и мы в любом случае не хотим, потому что тогда мы будем тестировать Excel, а не наш собственный код.

Вы можете увидеть это в действии в пример кода для следующей статьи RD News здесь ; эта статья будет обсуждать именно это, используя соединения ADODB. Принцип тот же: ни один из 94 модульных тестов в этом проекте никогда не открывал ни одного фактического соединения, но, тем не менее, с помощью внедрения зависимостей и интерфейсов-оболочек мы можем протестировать каждый бит функциональности, от открытия соединения с базой данных до совершения транзакции. ... не попав в реальную базу данных.

...