I am trying to build a model with VBA doing some work for me. Assume I have 4 variables - unit, lease start date, lease p.a, and alternative lease p.a. There are more, but that does not matter for my problem. VBA loops through each line and gets the value for unit i in respective column.
So, logically, I could declare unit as string, lease start date as date, lease p.a as single, and alternative lease p.a. as single. The problem I have is that I need to distinguish between empty entry and 0. The default numerical value is going to be 0. the distinction between 0 and empty is crucial. The only way I found to get around this is to declare everything as Variant and then check if the corresponding range is empty. If it is empty, then lease the Variant default value (Empty), otherwise assign the value.
I have a feeling that this is going to seriously affect my code performance. Ultimately, there will be lots of variables and I want to refer to those variables in the code. Like, if isempty(AltLease) = true then do one thing, otherwise something else.
I also find that I can not empty single or date variables(date is actually not a problem, since it drops to 1900). Can anyone suggest something?
Here is the code:
Dim tUnitName As Variant
Dim tNumberOfUnits As Variant
Dim tLeaseCurLeaseLengthDef as Variant
Dim tLeaseCurLeaseLengthAlt as Variant
Sub tenancyScheduleNew()
Dim lRow As Long
Dim i As Long
lRow = Sheet2.Cells(Rows.Count, 2).End(xlUp).Row
For i = 3 To lRow
reAssignVariables i
Next i
End Sub
Sub reAssignVariables(i As Long)
tAssetName = checkIfEmpty(i, getColumn("Sheet4", "tAssetName", 3))
tNumberOfUnits = checkIfEmpty(i, getColumn("Sheet4", "tNumberOfUnits", 3))
tLeaseCurLeaseLengthDef = checkIfEmpty(i, getColumn("Sheet4", "tLeaseCurLeaseLengthDef", 3))
tLeaseCurLeaseLengthDef = checkIfEmpty(i, getColumn("Sheet4", "tLeaseCurLeaseLengthAlt", 3))
End Sub
Function getColumn(sh As String, wh As String, colNo As Long) As Long
Dim refSheet As Worksheet
Dim rFound As Range
Set refSheet = Sheets(sh)
With refSheet
Set rFound = .Columns(1).Find(What:=wh, After:=.Cells(1, 1), LookIn:=xlValues, LookAt:=xlWhole)
On Error GoTo 0
If Not rFound Is Nothing Then
getColumn = rFound.Offset(0, colNo - 1).Value
Else
End If
End With
End Function
This is the way I am doing it now, which I think will slow the performance down. This is only small part of variables that I have made - there is going to be much more. I just need to understand how to construct it correctly in the first place. More specifically, if there is a value in tLeaseCurLeaseLengthAlt, then code should use that, alternatively, use default value.
You can't empty a variable of type integer, since empty is not an integer. If you have a variant variable which is currently of subtype integer you can reset it to empty:
Sub test()
Dim v As Variant
Debug.Print TypeName(v)
v = 1
Debug.Print TypeName(v)
v = Empty
Debug.Print TypeName(v)
End Sub
output:
Empty
Integer
Empty
Also, the performance hit of using variants might not be as great as you fear. An informal test:
Sub InformalTest(n As Long)
Dim i As Long, sum1 As Double
Dim j As Variant, sum2 As Variant
Dim start As Double, elapsed1 As Double, elapsed2 As Double
start = Timer
For i = 1 To n
sum1 = sum1 + 1#
Next i
elapsed1 = Timer - start
start = Timer
For j = 1 To n
sum2 = sum2 + 1#
Next j
elapsed2 = Timer - start
Debug.Print "Nonvariant time: " & elapsed1 & ", Nonvariant sum: " & sum1
Debug.Print "Variant time: " & elapsed2 & ", Variant sum: " & sum2
End Sub
Sample output:
InformalTest 1000000
Nonvariant time: 0.060546875, Nonvariant sum: 1000000
Variant time: 0.099609375, Variant sum: 1000000
InformalTest 10000000
Nonvariant time: 0.521484375, Nonvariant sum: 10000000
Variant time: 0.599609375, Variant sum: 10000000
Maybe you could create your own classes? Example for Single.
Class Single2
Private m_value As Single
Private m_hasValue As Boolean
Public Property Let Initialize(ByVal source As Range)
m_hasValue = False
m_value = 0
If source Is Nothing Then _
Exit Property
' add any checks you need to recognize the source cell as non-empty
' ... to distinguish between empty entry and 0
If Trim(source.Value) = "" Then _
Exit Property
If Not IsNumeric(source.Value) Then _
Exit Property
m_value = CSng(source.Value)
m_hasValue = True
End Property
Public Property Get Value() As Single
Value = m_value
End Property
Public Property Get HasValue() As Boolean
HasValue = m_hasValue
End Property
And use the class like this:
Module:
Dim lease As Single2
Set lease = New Single2
lease.Initialize = Range("a1")
If lease.HasValue Then
Debug.Print "lease has value ... " & lease.Value
Else
Debug.Print "lease hasn't value ... "
End If
Related
I'm new in VBA. I want to make a random pick cycle like that:
Let's say I have seven elements in an array(1,2,3,4,5,6,7), each time when I pick one element from the array, the total number of elements will decrease by 1. After picking every element, the array will be reset to what I initially defined (1,2,3,4,5,6,7) and do the random pick cycle again.
The result of every cycle should be different.
Is it possible to do that in VBA?
Here's a stateful function that does what you described each time it is called.
Option Base 1
Dim digits, NLeft
Function RemoveDigit() as Integer
Dim Element as Integer
If IsEmpty(digits) or NLeft = 0 Then
digits = array(1,2,3,4,5,6,7)
NLeft = 7
End If
Element = WorksheetFunction.RandBetween(1,NLeft)
RemoveDigit = digits(Element)
digits(Element) = digits(NLeft)
digits(NLeft) = RemoveDigit
NLeft = NLeft - 1
End Function
It uses a well known algorithm to arrange digits in a random order. Basically you choose to swap a random element number with the last element. Then you repeat it on an n - 1 sized array, making it a tail-recursive algorithm (although this implementation of it is not recursive).
Delete this if you want to, but here is a suggestion for a test sub:
Sub TestRemoveDigit()
NLeft = 0
For i = 1 To 7
d = RemoveDigit()
Debug.Print (d)
Next i
End Sub
I think this should do what you're asking for:
Option Explicit
Global vCurrentArray As Variant
Sub ResetArray()
vCurrentArray = Array(1, 2, 3, 4, 5, 6, 7)
End Sub
Sub RemoveElementWithIndex(lIndex As Long)
Dim vTemp() As Variant '* Change the type as needed
Dim lLBound As Long: lLBound = LBound(vCurrentArray)
Dim lUBound As Long: lUBound = UBound(vCurrentArray)
Dim i As Long, v As Variant
Dim blSkipped As Boolean
If lLBound = lUBound Then '* only 1 element
Call ResetArray
Else
ReDim vTemp(lLBound To lUBound - 1)
i = lLBound
For Each v In vCurrentArray
If i <> lIndex Or blSkipped Then
vTemp(i) = v
i = i + 1
Else
blSkipped = True
End If
Next v
vCurrentArray = vTemp
End If
End Sub
Function GetRandomElement() As Variant '* Change the type as needed
Dim lRandomIndex As Long
lRandomIndex = WorksheetFunction.RandBetween(LBound(vCurrentArray), UBound(vCurrentArray))
GetRandomElement = vCurrentArray(lRandomIndex)
RemoveElementWithIndex lRandomIndex
End Function
Sub TestCycles()
Dim lCycle As Long
Dim i As Long
ResetArray
For lCycle = 1 To 3
Debug.Print
For i = 1 To 7
Debug.Print "Cycle: " & lCycle, "i: " & i, "Random Elem: " & GetRandomElement
Next i
Next lCycle
End Sub
Note: There're many ways of achieving the end result. The above is almost a literal translation of your post.
We can not remove a random element from an array. We can redim array to remove last element(s). If you want to remove random element, you can use collection instead like ..
Option Explicit
Sub RemoveRandom()
Dim coll As Collection, cl As Variant, i As Long, j As Long
Set coll = New Collection
For Each cl In Range("A1:A7")
coll.Add cl.Value
Next cl
For j = 1 To coll.Count
i = WorksheetFunction.RandBetween(1, coll.Count)
Debug.Print coll(i)
coll.Remove (i)
Next j
End Sub
Is there any possible way to take a list of items or names, such as:
Apples
Oranges
Grapes
Watermelons
And have Excel double that information and sequentially number it, like this:
Apples1
Apples2
Oranges1
Oranges2
Grapes1
Grapes2
Watermelons1
Watermelons2
I know a little bit of VBA but I can't wrap my head around how I would even start this.
You can specify where you want to read, and where you want to start write and how many times you want to repeat!
Just change the code:
Sub DoRepeat()
Dim repeatTimes As Integer
Dim rng As Range, cell As Range
repeatTimes = 2
Set cellsToRead = Range("A1:A3")
Set cellStartToWrite = Range("B1")
For Each cell In cellsToRead
For i = 1 To repeatTimes
cellStartToWrite.Value = cell.Value + CStr(i)
Set cellStartToWrite = Cells(cellStartToWrite.Row + 1, cellStartToWrite.Column)
Next
Next cell
End Sub
As it seems it is required to have a more dynamic approach, try this out. The DoubleNames function will return the names duplicated N number of times specified in the DuplicateCount parameter. It will return a Collection, which you can easily dump to a range if need be.
Public Function DoubleNames(ByVal DataRange As Excel.Range, DuplicateCount As Long) As Collection
Set DoubleNames = New Collection
Dim dict As Object: Set dict = CreateObject("Scripting.Dictionary")
Dim i As Long
Dim DataItem As Excel.Range
Set DataRange = DataRange.SpecialCells(xlCellTypeConstants)
For Each DataItem In DataRange
For i = 1 To DuplicateCount
If Not dict.Exists(DataItem.Value) Then
DoubleNames.Add (DataItem.Value & "1")
dict.Add DataItem.Value, 1
Else
dict(DataItem.Value) = dict(DataItem.Value) + 1
DoubleNames.Add (DataItem.Value & dict(DataItem.Value))
End If
Next
Next
End Function
Sub ExampleUsage()
Dim item As Variant
Dim rng As Range: Set rng = ThisWorkbook.Sheets("Sheet1").Range("A1:A5")
For Each item In DoubleNames(rng, 5)
Debug.Print item
Next
End Sub
I would start by writing a general function that outputs the names (passed as a variant array) a given number of times:
Public Sub OutputNames(ByVal TimesToOutput As Integer, ByRef names() As Variant)
Dim nameIndex As Integer, outputIndex As Integer
For nameIndex = LBound(names) To UBound(names)
For outputIndex = 1 To TimesToOutput
Debug.Print names(nameIndex) & outputIndex
Next outputIndex
Next nameIndex
End Sub
Here you can see the sub that tests this:
Public Sub testOutputNames()
Dim names() As Variant
names = Array("Apples", "Oranges", "Grapes", "Watermelons")
OutputNames 2, names
End Sub
which gives you this output:
Apples1
Apples2
Oranges1
Oranges2
Grapes1
Grapes2
Watermelons1
Watermelons2
I currently facing a problem on how to determine the size of array after looping ends. Below is my coding for that particular function.
Function analyse(ByVal work_date As Date, time As String, action As String, decision As Boolean, branch As String) As String
Dim sh As Worksheet
Dim att_time(6) As String
Dim dated(1) As Date
Set sh = Worksheets("shifthr")
lastrec = sh.Range("C" & Rows.Count).End(xlUp).Row
a = 1
For i = 2 To lastrec
dated(a) = work_date
If branch = sh.Cells(i, 1) Then
att_time(a) = time
a = a + 1
End If
Next i
If att_time(a) = 4 Then
ElseIf att_time(a) = 6 Then
End If
End Function
Thanks for you guys helping hands. Appreciate it very much
I currently facing a problem on how to determine the size of array after looping ends.
You have already declared the size of the array in your dim statement. I guess you want to find the number of elements in the array? If yes, then try this (UNTESTED) Also I guess you wanted to keep dated(a) = work_date inside the IF Condition? If not then you don't need to use a to fill the dated(a) = work_date. Also I don't think you need a Function for this as you are not returning any value to analyse. Use a Sub
Sub analyse(ByVal work_date As Date, time As String, _
action As String, decision As Boolean, branch As String)
Dim sh As Worksheet
Dim att_time() As String
Dim dated() As Date
Dim a As Long
Dim boolCheck As Boolean
Set sh = Worksheets("shifthr")
lastrec = sh.Range("C" & sh.Rows.Count).End(xlUp).Row
a = 1
For i = 2 To lastrec
If branch = sh.Cells(i, 1) Then
ReDim Preserve dated(a)
ReDim Preserve att_time(a)
dated(a) = work_date
att_time(a) = time
a = a + 1
boolCheck = True
End If
Next i
If boolCheck = True Then
Debug.Print UBound(dated)
Debug.Print UBound(att_time)
'OR
Debug.Print a - 1
Else
Debug.Print "No Matching records found"
End If
End Sub
Perhaps you could alter your dim statement to dimension your initial array as () and then use redim preserve as you are looping "a" to increase the count - http://msdn.microsoft.com/en-us/library/w8k3cys2.aspx.
If you want to find out the size of the array, you could use ubound(arrayname). http://msdn.microsoft.com/en-us/library/95b8f22f(v=vs.90).aspx
I don't have much experience but I'm trying to write a function that will search column A and the 1st time it finds a string beginning with "AT" it will copy that whole string to Cell N1, the 2nd string beginning with "AT" will be copied to N2, so on and so forth until column A is exhausted. This is my feeble attempt so far but I'm not having much luck.
Function Find_AT(ByVal I As Integer)
Dim c As Range
Dim COUNTER As Integer
Dim CAPTURE As Long
COUNTER = 0
For Each c In Range("A1", Range("A65636").End(xlUp))
If Left(c, 2) = AT Then
COUNTER = COUNTER + 1
If COUNTER = I Then
CAPTURE = c
Exit For
End If
End If
Next c
Find_AT = CAPTURE
End Function
Consider:
Function Find_AT(ByVal I As Long) As String
Dim c As Range
Dim COUNTER As Long
Dim CAPTURE As String
Dim v As String
COUNTER = 0
CAPTURE = "xx"
For Each c In Range("A1", Range("A65636").End(xlUp))
v = c.Text & " "
If Left(v, 2) = "AT" Then
COUNTER = COUNTER + 1
If COUNTER = I Then
CAPTURE = c.Address
Exit For
End If
End If
Next c
Find_AT = CAPTURE
End Function
The error with your code is that the text (the string) AT needs to be enclosed in double-quotes "AT". Add Option Explicit to the top of the Module and it would take you to this error when you try to compile or execute the function.
However, given your description, I suspect that you might want to write a sub-procedure (SUB) not a Function. A function is intended to return a value. If you want to use a function you might define it like this:
Function Find_AT(rng As Range, ByVal i As Integer)
That is, you would supply it a Range to search and the number 1 to find the first value in the range that begins with "AT". However, if you put this function in a cell and copy it down, it will still return only the first occurrence. You would need to manually change 1 to 2, 3, etc. (or use a variation of ROW() to automatically generate this sequence).
Anyway, I suspect you really want a SUB-procedure that you might run by clicking a button on the worksheet.
If you wish to continue with your current function, then you could declare the return type as a string:
Function Find_AT(ByVal i As Integer) As String
'...
Dim CAPTURE As String
'...
CAPTURE = c.Text
Otherwise, setting CAPTURE = c and attempting to return this value causes a problem because c is a Range object.
Filtering is much more efficient. Two approaches below:
Filter
Sub GetAT1()
X = Filter(Application.Transpose(Range([a1], Cells(Rows.Count, "A").End(xlUp))), "AT", True)
If UBound(X) > 0 Then [n1].Resize(UBound(X) + 1) = Application.Transpose(X)
End Sub
AutoFilter
Sub GetAT()
Dim rng1 As Range
Set rng1 = Range([a1], Cells(Rows.Count, "A").End(xlUp))
Application.ScreenUpdating = False
ActiveSheet.AutoFilterMode = False
rng1.AutoFilter 1, "=AT*"
rng1.Copy [n1]
If LCase$(Left$([n1], 2)) <> "at" Then [n1].Delete xlUp
ActiveSheet.AutoFilterMode = False
Application.ScreenUpdating = True
End Sub
Here is the code that applies an advanced filter to the column A on the Sheet1 worksheet (List range) by using the range of values on the Sheet2 (criteria range):
Range("A1:A100").AdvancedFilter Action:=xlFilterInPlace, CriteriaRange:= _
Sheets("Sheet2").Range("A1:A10"), Unique:=False
After running this code, I need to do something with the rows that are currently visible on the screen.
Currently I use a code like this:
For i = 1 to maxRow
If Not ActiveSheet.Row(i).Hidden then
...do something that I need to do with that rows
EndIf
Next
Is there any simple property that can give me a range of rows visible after applying an advanced filter?
ActiveSheet.Range("A1:A100").Rows.SpecialCells(xlCellTypeVisible)
This yields a Range object.
Lance's solution will work in the majority of situations.
But if you deal with large/complex spreadsheets you might run into the "SpecialCells Problem". In a nutshell, if the range created causes greater than 8192 non-contiguous areas (and it can happen) then Excel will throw an error when you attempt to access SpecialCells and your code won't run. If your worksheets are complex enough you expect to encounter this problem, then it is recommended you stick with the looping approach.
It's worth noting that this problem is not with the SpecialCells property itself, rather it is with the Range object. This means that anytime that you attempt to obtain a range object that could be very complex you should either employee an error handler, or do as you already have done, which is to cause your program to work on each element of the range (split the range up).
Another possible approach would be to return an array of Range Objects and then loop through the array. I have posted some example code to play around with. However it should be noted that you really should only bother with this if you expect to have the problem described or you just want to feel assured your code is robust. Otherwise it's just needless complexity.
Option Explicit
Public Declare Function GetTickCount Lib "kernel32" () As Long
Public Sub GenerateProblem()
'Run this to set up an example spreadsheet:
Dim row As Long
Excel.Application.EnableEvents = False
Sheet1.AutoFilterMode = False
Sheet1.UsedRange.Delete
For row = 1 To (8192& * 4&) + 1&
If row Mod 3& Then If Int(10& * Rnd) 7& Then Sheet1.Cells(row, 1&).value = "test"
Next
Sheet1.UsedRange.AutoFilter 1&, ""
Excel.Application.EnableEvents = True
MsgBox Sheet1.UsedRange.SpecialCells(xlCellTypeVisible).address
End Sub
Public Sub FixProblem()
'Run this to see various solutions:
Dim ranges() As Excel.Range
Dim index As Long
Dim address As String
Dim startTime As Long
Dim endTime As Long
'Get range array.
ranges = GetVisibleRows
'Do something with individual range objects.
For index = LBound(ranges) To UBound(ranges)
ranges(index).Interior.ColorIndex = Int(56 * Rnd + 1)
Next
'Get total address if you want it:
startTime = GetTickCount
address = RangeArrayAddress(ranges)
endTime = GetTickCount
Debug.Print endTime - startTime, ; 'Outputs time elapsed in milliseconds.
'Small demo of why I used a string builder. Straight concatenation is about
'10 times slower:
startTime = GetTickCount
address = RangeArrayAddress2(ranges)
endTime = GetTickCount
Debug.Print endTime - startTime
End Sub
Public Function GetVisibleRows(Optional ByVal ws As Excel.Worksheet) As Excel.Range()
Const increment As Long = 1000&
Dim max As Long
Dim row As Long
Dim returnVal() As Excel.Range
Dim startRow As Long
Dim index As Long
If ws Is Nothing Then Set ws = Excel.ActiveSheet
max = increment
ReDim returnVal(max) As Excel.Range
For row = ws.UsedRange.row To ws.UsedRange.Rows.Count
If Sheet1.Rows(row).Hidden Then
If startRow 0& Then
Set returnVal(index) = ws.Rows(startRow & ":" & (row - 1&))
index = index + 1&
If index > max Then
'Redimming in large increments is an optimization trick.
max = max + increment
ReDim Preserve returnVal(max) As Excel.Range
End If
startRow = 0&
End If
ElseIf startRow = 0& Then startRow = row
End If
Next
ReDim Preserve returnVal(index - 1&) As Excel.Range
GetVisibleRows = returnVal
End Function
Public Function RangeArrayAddress(ByRef value() As Excel.Range, Optional lowerindexRV As Variant, Optional upperindexRV As Variant) As String
'Parameters left as variants to allow for "IsMissing" values.
'Code uses bytearray string building methods to run faster.
Const incrementChars As Long = 1000&
Const unicodeWidth As Long = 2&
Const comma As Long = 44&
Dim increment As Long
Dim max As Long
Dim index As Long
Dim returnVal() As Byte
Dim address() As Byte
Dim indexRV As Long
Dim char As Long
increment = incrementChars * unicodeWidth 'Double for unicode.
max = increment - 1& 'Offset for array.
ReDim returnVal(max) As Byte
If IsMissing(lowerindexRV) Then lowerindexRV = LBound(value)
If IsMissing(upperindexRV) Then upperindexRV = UBound(value)
For index = lowerindexRV To upperindexRV
address = value(index).address
For char = 0& To UBound(address) Step unicodeWidth
returnVal(indexRV) = address(char)
indexRV = indexRV + unicodeWidth
If indexRV > max Then
max = max + increment
ReDim Preserve returnVal(max) As Byte
End If
Next
returnVal(indexRV) = comma
indexRV = indexRV + unicodeWidth
If indexRV > max Then
max = max + increment
ReDim Preserve returnVal(max) As Byte
End If
Next
ReDim Preserve returnVal(indexRV - 1&) As Byte
RangeArrayAddress = returnVal
End Function
Public Function RangeArrayAddress2(ByRef value() As Excel.Range, Optional lowerIndex As Variant, Optional upperIndex As Variant) As String
'Parameters left as variants to allow for "IsMissing" values.
'Code uses bytearray string building methods to run faster.
Const incrementChars As Long = 1000&
Const unicodeWidth As Long = 2&
Dim increment As Long
Dim max As Long
Dim returnVal As String
Dim index As Long
increment = incrementChars * unicodeWidth 'Double for unicode.
max = increment - 1& 'Offset for array.
If IsMissing(lowerIndex) Then lowerIndex = LBound(value)
If IsMissing(upperIndex) Then upperIndex = UBound(value)
For index = lowerIndex To upperIndex
returnVal = returnVal & (value(index).address & ",")
Next
RangeArrayAddress2 = returnVal
End Function
You can use the following code to get the visible range of cells:
Excel.Range visibleRange = Excel.Application.ActiveWindow.VisibleRange
Hope this helps.